kerykeion 4.26.3__py3-none-any.whl → 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kerykeion might be problematic. Click here for more details.

Files changed (76) hide show
  1. kerykeion/__init__.py +54 -11
  2. kerykeion/aspects/__init__.py +5 -2
  3. kerykeion/aspects/aspects_factory.py +569 -0
  4. kerykeion/aspects/aspects_utils.py +81 -8
  5. kerykeion/astrological_subject_factory.py +1897 -0
  6. kerykeion/backword.py +773 -0
  7. kerykeion/chart_data_factory.py +549 -0
  8. kerykeion/charts/chart_drawer.py +2601 -0
  9. kerykeion/charts/charts_utils.py +948 -177
  10. kerykeion/charts/draw_planets.py +602 -351
  11. kerykeion/charts/templates/aspect_grid_only.xml +328 -202
  12. kerykeion/charts/templates/chart.xml +432 -272
  13. kerykeion/charts/templates/wheel_only.xml +350 -214
  14. kerykeion/charts/themes/black-and-white.css +148 -0
  15. kerykeion/charts/themes/classic.css +107 -76
  16. kerykeion/charts/themes/dark-high-contrast.css +145 -107
  17. kerykeion/charts/themes/dark.css +146 -107
  18. kerykeion/charts/themes/light.css +146 -103
  19. kerykeion/charts/themes/strawberry.css +158 -0
  20. kerykeion/composite_subject_factory.py +253 -51
  21. kerykeion/ephemeris_data_factory.py +434 -0
  22. kerykeion/fetch_geonames.py +27 -8
  23. kerykeion/house_comparison/__init__.py +6 -0
  24. kerykeion/house_comparison/house_comparison_factory.py +103 -0
  25. kerykeion/house_comparison/house_comparison_utils.py +126 -0
  26. kerykeion/kr_types/__init__.py +66 -6
  27. kerykeion/kr_types/chart_template_model.py +20 -0
  28. kerykeion/kr_types/kerykeion_exception.py +15 -9
  29. kerykeion/kr_types/kr_literals.py +14 -132
  30. kerykeion/kr_types/kr_models.py +14 -318
  31. kerykeion/kr_types/settings_models.py +15 -203
  32. kerykeion/planetary_return_factory.py +805 -0
  33. kerykeion/relationship_score_factory.py +301 -0
  34. kerykeion/report.py +751 -64
  35. kerykeion/schemas/__init__.py +106 -0
  36. kerykeion/schemas/chart_template_model.py +367 -0
  37. kerykeion/schemas/kerykeion_exception.py +20 -0
  38. kerykeion/schemas/kr_literals.py +181 -0
  39. kerykeion/schemas/kr_models.py +605 -0
  40. kerykeion/schemas/settings_models.py +180 -0
  41. kerykeion/settings/__init__.py +20 -1
  42. kerykeion/settings/chart_defaults.py +444 -0
  43. kerykeion/settings/config_constants.py +117 -12
  44. kerykeion/settings/kerykeion_settings.py +31 -73
  45. kerykeion/settings/translation_strings.py +1479 -0
  46. kerykeion/settings/translations.py +74 -0
  47. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  48. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  50. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  51. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  52. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  54. kerykeion/sweph/sefstars.txt +1602 -0
  55. kerykeion/transits_time_range_factory.py +302 -0
  56. kerykeion/utilities.py +393 -114
  57. kerykeion-5.0.0.dist-info/METADATA +1176 -0
  58. kerykeion-5.0.0.dist-info/RECORD +63 -0
  59. {kerykeion-4.26.3.dist-info → kerykeion-5.0.0.dist-info}/WHEEL +1 -1
  60. kerykeion/aspects/natal_aspects.py +0 -172
  61. kerykeion/aspects/synastry_aspects.py +0 -124
  62. kerykeion/aspects/transits_time_range.py +0 -41
  63. kerykeion/astrological_subject.py +0 -841
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -1219
  65. kerykeion/enums.py +0 -57
  66. kerykeion/ephemeris_data.py +0 -242
  67. kerykeion/kr_types/chart_types.py +0 -95
  68. kerykeion/relationship_score/__init__.py +0 -2
  69. kerykeion/relationship_score/relationship_score.py +0 -175
  70. kerykeion/relationship_score/relationship_score_factory.py +0 -230
  71. kerykeion/settings/kr.config.json +0 -1258
  72. kerykeion/transits_time_range.py +0 -124
  73. kerykeion-4.26.3.dist-info/METADATA +0 -634
  74. kerykeion-4.26.3.dist-info/RECORD +0 -45
  75. kerykeion-4.26.3.dist-info/entry_points.txt +0 -3
  76. {kerykeion-4.26.3.dist-info → kerykeion-5.0.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,9 +1,230 @@
1
1
  import math
2
2
  import datetime
3
- from kerykeion.kr_types import KerykeionException, ChartType
4
- from typing import Union, Literal
5
- from kerykeion.kr_types.kr_models import AspectModel, KerykeionPointModel
6
- from kerykeion.kr_types.settings_models import KerykeionLanguageCelestialPointModel, KerykeionSettingsAspectModel
3
+ from typing import Mapping, Optional, Sequence, Union, Literal
4
+
5
+ from kerykeion.schemas import KerykeionException, ChartType
6
+ from kerykeion.schemas.kr_literals import AstrologicalPoint
7
+ from kerykeion.schemas.kr_models import AspectModel, KerykeionPointModel, CompositeSubjectModel, PlanetReturnModel, AstrologicalSubjectModel, HouseComparisonModel
8
+ from kerykeion.schemas.settings_models import KerykeionLanguageCelestialPointModel, KerykeionSettingsCelestialPointModel
9
+
10
+
11
+ ElementQualityDistributionMethod = Literal["pure_count", "weighted"]
12
+ """Supported strategies for calculating element and modality distributions."""
13
+
14
+ _SIGN_TO_ELEMENT: tuple[str, ...] = (
15
+ "fire", # Aries
16
+ "earth", # Taurus
17
+ "air", # Gemini
18
+ "water", # Cancer
19
+ "fire", # Leo
20
+ "earth", # Virgo
21
+ "air", # Libra
22
+ "water", # Scorpio
23
+ "fire", # Sagittarius
24
+ "earth", # Capricorn
25
+ "air", # Aquarius
26
+ "water", # Pisces
27
+ )
28
+
29
+ _SIGN_TO_QUALITY: tuple[str, ...] = (
30
+ "cardinal", # Aries
31
+ "fixed", # Taurus
32
+ "mutable", # Gemini
33
+ "cardinal", # Cancer
34
+ "fixed", # Leo
35
+ "mutable", # Virgo
36
+ "cardinal", # Libra
37
+ "fixed", # Scorpio
38
+ "mutable", # Sagittarius
39
+ "cardinal", # Capricorn
40
+ "fixed", # Aquarius
41
+ "mutable", # Pisces
42
+ )
43
+
44
+ _ELEMENT_KEYS: tuple[str, ...] = ("fire", "earth", "air", "water")
45
+ _QUALITY_KEYS: tuple[str, ...] = ("cardinal", "fixed", "mutable")
46
+
47
+ _DEFAULT_WEIGHTED_FALLBACK = 1.0
48
+ DEFAULT_WEIGHTED_POINT_WEIGHTS: dict[str, float] = {
49
+ # Core luminaries & angles
50
+ "sun": 2.0,
51
+ "moon": 2.0,
52
+ "ascendant": 2.0,
53
+ "medium_coeli": 1.5,
54
+ "descendant": 1.5,
55
+ "imum_coeli": 1.5,
56
+ "vertex": 0.8,
57
+ "anti_vertex": 0.8,
58
+ # Personal planets
59
+ "mercury": 1.5,
60
+ "venus": 1.5,
61
+ "mars": 1.5,
62
+ # Social planets
63
+ "jupiter": 1.0,
64
+ "saturn": 1.0,
65
+ # Outer/transpersonal
66
+ "uranus": 0.5,
67
+ "neptune": 0.5,
68
+ "pluto": 0.5,
69
+ # Lunar nodes (mean/true variants)
70
+ "mean_north_lunar_node": 0.5,
71
+ "true_north_lunar_node": 0.5,
72
+ "mean_south_lunar_node": 0.5,
73
+ "true_south_lunar_node": 0.5,
74
+ # Chiron, Lilith variants
75
+ "chiron": 0.6,
76
+ "mean_lilith": 0.5,
77
+ "true_lilith": 0.5,
78
+ # Asteroids / centaurs
79
+ "ceres": 0.5,
80
+ "pallas": 0.4,
81
+ "juno": 0.4,
82
+ "vesta": 0.4,
83
+ "pholus": 0.3,
84
+ # Dwarf planets & TNOs
85
+ "eris": 0.3,
86
+ "sedna": 0.3,
87
+ "haumea": 0.3,
88
+ "makemake": 0.3,
89
+ "ixion": 0.3,
90
+ "orcus": 0.3,
91
+ "quaoar": 0.3,
92
+ # Arabic Parts
93
+ "pars_fortunae": 0.8,
94
+ "pars_spiritus": 0.7,
95
+ "pars_amoris": 0.6,
96
+ "pars_fidei": 0.6,
97
+ # Fixed stars
98
+ "regulus": 0.2,
99
+ "spica": 0.2,
100
+ # Other
101
+ "earth": 0.3,
102
+ }
103
+
104
+
105
+ def _prepare_weight_lookup(
106
+ method: ElementQualityDistributionMethod,
107
+ custom_weights: Optional[Mapping[str, float]] = None,
108
+ ) -> tuple[dict[str, float], float]:
109
+ """
110
+ Normalize and merge default weights with any custom overrides.
111
+
112
+ Args:
113
+ method: Calculation strategy to use.
114
+ custom_weights: Optional mapping of point name (case-insensitive) to weight.
115
+ Supports special key "__default__" as fallback weight.
116
+
117
+ Returns:
118
+ A tuple containing the weight lookup dictionary and fallback weight.
119
+ """
120
+ normalized_custom = (
121
+ {key.lower(): float(value) for key, value in custom_weights.items()}
122
+ if custom_weights
123
+ else {}
124
+ )
125
+
126
+ if method == "weighted":
127
+ weight_lookup: dict[str, float] = dict(DEFAULT_WEIGHTED_POINT_WEIGHTS)
128
+ fallback_weight = _DEFAULT_WEIGHTED_FALLBACK
129
+ else:
130
+ weight_lookup = {}
131
+ fallback_weight = 1.0
132
+
133
+ fallback_weight = normalized_custom.get("__default__", fallback_weight)
134
+
135
+ for key, value in normalized_custom.items():
136
+ if key == "__default__":
137
+ continue
138
+ weight_lookup[key] = float(value)
139
+
140
+ return weight_lookup, fallback_weight
141
+
142
+
143
+ def _calculate_distribution_for_subject(
144
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
145
+ celestial_points_names: Sequence[str],
146
+ sign_to_group_map: Sequence[str],
147
+ group_keys: Sequence[str],
148
+ weight_lookup: Mapping[str, float],
149
+ fallback_weight: float,
150
+ ) -> dict[str, float]:
151
+ """
152
+ Accumulate distribution totals for a single subject.
153
+
154
+ Args:
155
+ subject: Subject providing planetary positions.
156
+ celestial_points_names: Names of celestial points to consider (lowercase).
157
+ sign_to_group_map: Mapping from sign index to element/modality key.
158
+ group_keys: Iterable of expected keys for the resulting totals.
159
+ weight_lookup: Precomputed mapping of weights per point.
160
+ fallback_weight: Default weight if point missing in lookup.
161
+
162
+ Returns:
163
+ Dictionary with accumulated totals keyed by element/modality.
164
+ """
165
+ totals = {key: 0.0 for key in group_keys}
166
+
167
+ for point_name in celestial_points_names:
168
+ point = subject.get(point_name)
169
+ if point is None:
170
+ continue
171
+
172
+ sign_index = getattr(point, "sign_num", None)
173
+ if sign_index is None or not (0 <= sign_index < len(sign_to_group_map)):
174
+ continue
175
+
176
+ group_key = sign_to_group_map[sign_index]
177
+ weight = weight_lookup.get(point_name, fallback_weight)
178
+ totals[group_key] += weight
179
+
180
+ return totals
181
+
182
+
183
+ _SECOND_COLUMN_THRESHOLD = 20
184
+ _THIRD_COLUMN_THRESHOLD = 28
185
+ _FOURTH_COLUMN_THRESHOLD = 36
186
+
187
+ _DOUBLE_CHART_TYPES: tuple[ChartType, ...] = ("Synastry", "Transit", "DualReturnChart")
188
+ _GRID_COLUMN_WIDTH = 125
189
+
190
+
191
+ def _select_planet_grid_thresholds(chart_type: ChartType) -> tuple[int, int, int]:
192
+ """Return column thresholds for the planet grids based on chart type."""
193
+ if chart_type in _DOUBLE_CHART_TYPES:
194
+ return (
195
+ 1_000_000, # effectively disable first column
196
+ 1_000_008, # effectively disable second column
197
+ 1_000_016, # effectively disable third column
198
+ )
199
+ return _SECOND_COLUMN_THRESHOLD, _THIRD_COLUMN_THRESHOLD, _FOURTH_COLUMN_THRESHOLD
200
+
201
+
202
+ def _planet_grid_layout_position(
203
+ index: int, thresholds: Optional[tuple[int, int, int]] = None
204
+ ) -> tuple[int, int]:
205
+ """Return horizontal offset and row index for planet grids."""
206
+ second_threshold, third_threshold, fourth_threshold = (
207
+ thresholds
208
+ if thresholds is not None
209
+ else (_SECOND_COLUMN_THRESHOLD, _THIRD_COLUMN_THRESHOLD, _FOURTH_COLUMN_THRESHOLD)
210
+ )
211
+
212
+ if index < second_threshold:
213
+ column = 0
214
+ row = index
215
+ elif index < third_threshold:
216
+ column = 1
217
+ row = index - second_threshold
218
+ elif index < fourth_threshold:
219
+ column = 2
220
+ row = index - third_threshold
221
+ else:
222
+ column = 3
223
+ row = index - fourth_threshold
224
+
225
+ offset = -(_GRID_COLUMN_WIDTH * column)
226
+ return offset, row
227
+
7
228
 
8
229
 
9
230
  def get_decoded_kerykeion_celestial_point_name(input_planet_name: str, celestial_point_language: KerykeionLanguageCelestialPointModel) -> str:
@@ -186,7 +407,7 @@ def draw_zodiac_slice(
186
407
  # pie slices
187
408
  offset = 360 - seventh_house_degree_ut
188
409
  # check transit
189
- if chart_type == "Transit" or chart_type == "Synastry":
410
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
190
411
  dropin: Union[int, float] = 0
191
412
  else:
192
413
  dropin = c1
@@ -195,7 +416,7 @@ def draw_zodiac_slice(
195
416
  # symbols
196
417
  offset = offset + 15
197
418
  # check transit
198
- if chart_type == "Transit" or chart_type == "Synastry":
419
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
199
420
  dropin = 54
200
421
  else:
201
422
  dropin = 18 + c1
@@ -284,11 +505,12 @@ def draw_aspect_line(
284
505
  y2 = sliceToY(0, ar, second_offset) + (r - ar)
285
506
 
286
507
  return (
287
- f'<g kr:node="Aspect" kr:aspectname="{aspect["aspect"]}" kr:to="{aspect["p1_name"]}" kr:tooriginaldegrees="{aspect["p1_abs_pos"]}" kr:from="{aspect["p2_name"]}" kr:fromoriginaldegrees="{aspect["p2_abs_pos"]}">'
508
+ f'<g kr:node="Aspect" kr:aspectname="{aspect["aspect"]}" kr:to="{aspect["p1_name"]}" kr:tooriginaldegrees="{aspect["p1_abs_pos"]}" kr:from="{aspect["p2_name"]}" kr:fromoriginaldegrees="{aspect["p2_abs_pos"]}" kr:orb="{aspect["orbit"]}" kr:aspectdegrees="{aspect["aspect_degrees"]}" kr:planetsdiff="{aspect["diff"]}" kr:aspectmovement="{aspect["aspect_movement"]}">'
288
509
  f'<line class="aspect" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {color}; stroke-width: 1; stroke-opacity: .9;"/>'
289
510
  f"</g>"
290
511
  )
291
512
 
513
+
292
514
  def convert_decimal_to_degree_string(dec: float, format_type: Literal["1", "2", "3"] = "3") -> str:
293
515
  """
294
516
  Converts a decimal float to a degrees string in the specified format.
@@ -415,7 +637,7 @@ def draw_first_circle(
415
637
  Returns:
416
638
  str: The SVG path of the first circle.
417
639
  """
418
- if chart_type == "Synastry" or chart_type == "Transit":
640
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
419
641
  return f'<circle cx="{r}" cy="{r}" r="{r - 36}" style="fill: none; stroke: {stroke_color}; stroke-width: 1px; stroke-opacity:.4;" />'
420
642
  else:
421
643
  if c1 is None:
@@ -426,6 +648,21 @@ def draw_first_circle(
426
648
  )
427
649
 
428
650
 
651
+ def draw_background_circle(r: Union[int, float], stroke_color: str, fill_color: str) -> str:
652
+ """
653
+ Draws the background circle.
654
+
655
+ Args:
656
+ - r (Union[int, float]): The value of r.
657
+ - stroke_color (str): The color of the stroke.
658
+ - fill_color (str): The color of the fill.
659
+
660
+ Returns:
661
+ str: The SVG path of the background circle.
662
+ """
663
+ return f'<circle cx="{r}" cy="{r}" r="{r}" style="fill: {fill_color}; stroke: {stroke_color}; stroke-width: 1px;" />'
664
+
665
+
429
666
  def draw_second_circle(
430
667
  r: Union[int, float], stroke_color: str, fill_color: str, chart_type: ChartType, c2: Union[int, float, None] = None
431
668
  ) -> str:
@@ -443,7 +680,7 @@ def draw_second_circle(
443
680
  str: The SVG path of the second circle.
444
681
  """
445
682
 
446
- if chart_type == "Synastry" or chart_type == "Transit":
683
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
447
684
  return f'<circle cx="{r}" cy="{r}" r="{r - 72}" style="fill: {fill_color}; fill-opacity:.4; stroke: {stroke_color}; stroke-opacity:.4; stroke-width: 1px" />'
448
685
 
449
686
  else:
@@ -473,7 +710,7 @@ def draw_third_circle(
473
710
  Returns:
474
711
  - str: The SVG element as a string.
475
712
  """
476
- if chart_type in {"Synastry", "Transit"}:
713
+ if chart_type in {"Synastry", "Transit", "DualReturnChart"}:
477
714
  # For Synastry and Transit charts, use a fixed radius adjustment of 160
478
715
  return f'<circle cx="{radius}" cy="{radius}" r="{radius - 160}" style="fill: {fill_color}; fill-opacity:.8; stroke: {stroke_color}; stroke-width: 1px" />'
479
716
 
@@ -485,7 +722,7 @@ def draw_aspect_grid(
485
722
  stroke_color: str,
486
723
  available_planets: list,
487
724
  aspects: list,
488
- x_start: int = 380,
725
+ x_start: int = 510,
489
726
  y_start: int = 468,
490
727
  ) -> str:
491
728
  """
@@ -506,7 +743,7 @@ def draw_aspect_grid(
506
743
  box_size = 14
507
744
 
508
745
  # Filter active planets
509
- active_planets = [planet for planet in available_planets if planet.is_active]
746
+ active_planets = [planet for planet in available_planets if planet["is_active"]]
510
747
 
511
748
  # Reverse the list of active planets for the first iteration
512
749
  reversed_planets = active_planets[::-1]
@@ -553,6 +790,7 @@ def draw_houses_cusps_and_text_number(
553
790
  chart_type: ChartType,
554
791
  second_subject_houses_list: Union[list[KerykeionPointModel], None] = None,
555
792
  transit_house_cusp_color: Union[str, None] = None,
793
+ external_view: bool = False,
556
794
  ) -> str:
557
795
  """
558
796
  Draws the houses cusps and text numbers for a given chart type.
@@ -570,9 +808,10 @@ def draw_houses_cusps_and_text_number(
570
808
  - chart_type: Type of the chart (e.g., Transit, Synastry).
571
809
  - second_subject_houses_list: List of house for the second subject (optional).
572
810
  - transit_house_cusp_color: Color for transit house cusps (optional).
811
+ - external_view: Whether to use external view mode for positioning (optional).
573
812
 
574
813
  Returns:
575
- - A string containing the SVG path for the houses cusps and text numbers.
814
+ - A string containing SVG elements for house cusps and numbers.
576
815
  """
577
816
 
578
817
  path = ""
@@ -580,7 +819,7 @@ def draw_houses_cusps_and_text_number(
580
819
 
581
820
  for i in range(xr):
582
821
  # Determine offsets based on chart type
583
- dropin, roff, t_roff = (160, 72, 36) if chart_type in ["Transit", "Synastry"] else (c3, c1, False)
822
+ dropin, roff, t_roff = (160, 72, 36) if chart_type in ["Transit", "Synastry", "DualReturnChart"] else (c3, c1, False)
584
823
 
585
824
  # Calculate the offset for the current house cusp
586
825
  offset = (int(first_subject_houses_list[int(xr / 2)].abs_pos) / -1) + int(first_subject_houses_list[i].abs_pos)
@@ -602,7 +841,7 @@ def draw_houses_cusps_and_text_number(
602
841
  i, standard_house_cusp_color
603
842
  )
604
843
 
605
- if chart_type in ["Transit", "Synastry"]:
844
+ if chart_type in ["Transit", "Synastry", "DualReturnChart"]:
606
845
  if second_subject_houses_list is None or transit_house_cusp_color is None:
607
846
  raise KerykeionException("second_subject_houses_list_ut or transit_house_cusp_color is None")
608
847
 
@@ -626,30 +865,34 @@ def draw_houses_cusps_and_text_number(
626
865
 
627
866
  # Add the house number text for the second subject
628
867
  fill_opacity = "0" if chart_type == "Transit" else ".4"
629
- path += f'<g kr:node="HouseNumber">'
868
+ path += '<g kr:node="HouseNumber">'
630
869
  path += f'<text style="fill: var(--kerykeion-chart-color-house-number); fill-opacity: {fill_opacity}; font-size: 14px"><tspan x="{xtext - 3}" y="{ytext + 3}">{i + 1}</tspan></text>'
631
- path += f"</g>"
870
+ path += "</g>"
632
871
 
633
872
  # Add the house cusp line for the second subject
634
873
  stroke_opacity = "0" if chart_type == "Transit" else ".3"
635
- path += f'<g kr:node="Cusp">'
874
+ path += f'<g kr:node="Cusp" kr:absoluteposition="{second_subject_houses_list[i].abs_pos}" kr:signposition="{second_subject_houses_list[i].position}" kr:sing="{second_subject_houses_list[i].sign}" kr:slug="{second_subject_houses_list[i].name}">'
636
875
  path += f"<line x1='{t_x1}' y1='{t_y1}' x2='{t_x2}' y2='{t_y2}' style='stroke: {t_linecolor}; stroke-width: 1px; stroke-opacity:{stroke_opacity};'/>"
637
- path += f"</g>"
876
+ path += "</g>"
638
877
 
639
- # Adjust dropin based on chart type
640
- dropin = {"Transit": 84, "Synastry": 84, "ExternalNatal": 100}.get(chart_type, 48)
878
+ # Adjust dropin based on chart type and external view
879
+ dropin_map = {"Transit": 84, "Synastry": 84, "DualReturnChart": 84}
880
+ if external_view:
881
+ dropin = 100
882
+ else:
883
+ dropin = dropin_map.get(chart_type, 48)
641
884
  xtext = sliceToX(0, (r - dropin), text_offset) + dropin
642
885
  ytext = sliceToY(0, (r - dropin), text_offset) + dropin
643
886
 
644
887
  # Add the house cusp line for the first subject
645
- path += f'<g kr:node="Cusp">'
888
+ path += f'<g kr:node="Cusp" kr:absoluteposition="{first_subject_houses_list[i].abs_pos}" kr:signposition="{first_subject_houses_list[i].position}" kr:sing="{first_subject_houses_list[i].sign}" kr:slug="{first_subject_houses_list[i].name}">'
646
889
  path += f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {linecolor}; stroke-width: 1px; stroke-dasharray:3,2; stroke-opacity:.4;"/>'
647
- path += f"</g>"
890
+ path += "</g>"
648
891
 
649
892
  # Add the house number text for the first subject
650
- path += f'<g kr:node="HouseNumber">'
893
+ path += '<g kr:node="HouseNumber">'
651
894
  path += f'<text style="fill: var(--kerykeion-chart-color-house-number); fill-opacity: .6; font-size: 14px"><tspan x="{xtext - 3}" y="{ytext + 3}">{i + 1}</tspan></text>'
652
- path += f"</g>"
895
+ path += "</g>"
653
896
 
654
897
  return path
655
898
 
@@ -658,7 +901,13 @@ def draw_transit_aspect_list(
658
901
  grid_title: str,
659
902
  aspects_list: Union[list[AspectModel], list[dict]],
660
903
  celestial_point_language: Union[KerykeionLanguageCelestialPointModel, dict],
661
- aspects_settings: Union[KerykeionSettingsAspectModel, dict],
904
+ aspects_settings: dict,
905
+ *,
906
+ aspects_per_column: int = 14,
907
+ column_width: int = 100,
908
+ line_height: int = 14,
909
+ max_columns: int = 6,
910
+ chart_height: Optional[int] = None,
662
911
  ) -> str:
663
912
  """
664
913
  Generates the SVG output for the aspect transit grid.
@@ -666,8 +915,14 @@ def draw_transit_aspect_list(
666
915
  Parameters:
667
916
  - grid_title: Title of the grid.
668
917
  - aspects_list: List of aspects.
669
- - planets_labels: Dictionary containing the planet labels.
918
+ - celestial_point_language: Dictionary containing the celestial point language data.
670
919
  - aspects_settings: Dictionary containing the aspect settings.
920
+ - aspects_per_column: Number of aspects to display per column (default: 14).
921
+ - column_width: Width in pixels for each column (default: 100).
922
+ - line_height: Height in pixels for each line (default: 14).
923
+ - max_columns: Maximum number of columns before vertical adjustment (default: 6).
924
+ - chart_height: Total chart height. When provided, columns from the 12th onward
925
+ leverage the taller layout capacity (default: None).
671
926
 
672
927
  Returns:
673
928
  - A string containing the SVG path data for the aspect transit grid.
@@ -676,68 +931,93 @@ def draw_transit_aspect_list(
676
931
  if isinstance(celestial_point_language, dict):
677
932
  celestial_point_language = KerykeionLanguageCelestialPointModel(**celestial_point_language)
678
933
 
679
- if isinstance(aspects_settings, dict):
680
- aspects_settings = KerykeionSettingsAspectModel(**aspects_settings)
681
-
682
934
  # If not instance of AspectModel, convert to AspectModel
683
- if isinstance(aspects_list[0], dict):
684
- aspects_list = [AspectModel(**aspect) for aspect in aspects_list] # type: ignore
935
+ if aspects_list and isinstance(aspects_list[0], dict):
936
+ aspects_list = [AspectModel(**aspect) for aspect in aspects_list] # type: ignore
937
+
938
+ # Type narrowing: at this point aspects_list contains AspectModel instances
939
+ typed_aspects_list: list[AspectModel] = aspects_list # type: ignore
940
+
941
+ translate_x = 565
942
+ translate_y = 273
943
+ title_clearance = 18
944
+ top_limit_y: float = -translate_y + title_clearance
945
+ bottom_padding = 40
946
+ baseline_index = aspects_per_column - 1
947
+ top_limit_index = math.ceil(top_limit_y / line_height)
948
+ # `top_limit_index` identifies the highest row index we can reach without
949
+ # touching the title block. Combined with the baseline index we know how many
950
+ # rows a "tall" column may contain.
951
+ max_capacity_by_top = baseline_index - top_limit_index + 1
685
952
 
686
- line = 0
687
- nl = 0
688
953
  inner_path = ""
689
- for i, aspect in enumerate(aspects_list):
690
- # Adjust the vertical position for every 12 aspects
691
- if i == 14:
692
- nl = 100
693
- line = 0
694
-
695
- elif i == 28:
696
- nl = 200
697
- line = 0
698
-
699
- elif i == 42:
700
- nl = 300
701
- line = 0
702
-
703
- elif i == 56:
704
- nl = 400
705
- line = 0
706
-
707
- elif i == 70:
708
- nl = 500
709
- # When there are more than 60 aspects, the text is moved up
710
- if len(aspects_list) > 84:
711
- line = -1 * (len(aspects_list) - 84) * 14
712
- else:
713
- line = 0
714
-
715
- inner_path += f'<g transform="translate({nl},{line})">'
716
-
717
- # first planet symbol
718
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspects_list[i]["p1"]]["name"]}" />'
719
-
720
- # aspect symbol
721
- # TODO: Remove the "degree" element EVERYWHERE!
722
- aspect_name = aspects_list[i]["aspect"]
723
- id_value = next((a["degree"] for a in aspects_settings if a["name"] == aspect_name), None) # type: ignore
724
- inner_path += f'<use x="15" y="0" xlink:href="#orb{id_value}" />'
725
-
726
- # second planet symbol
727
- inner_path += f'<g transform="translate(30,0)">'
728
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspects_list[i]["p2"]]["name"]}" />'
729
- inner_path += f"</g>"
730
-
731
- # difference in degrees
732
- inner_path += f'<text y="8" x="45" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 10px;">{convert_decimal_to_degree_string(aspects_list[i]["orbit"])}</text>'
733
- # line
734
- inner_path += f"</g>"
735
- line = line + 14
736
-
737
- out = '<g transform="translate(526,273)">'
954
+
955
+ full_height_column_index = 10 # 0-based index 11th column onward
956
+ if chart_height is not None:
957
+ available_height = max(chart_height - translate_y - bottom_padding, line_height)
958
+ allowed_capacity = max(aspects_per_column, int(available_height // line_height))
959
+ full_height_capacity = max(aspects_per_column, min(allowed_capacity, max_capacity_by_top))
960
+ else:
961
+ full_height_capacity = aspects_per_column
962
+
963
+ # Bucket aspects into columns while respecting the capacity of each column.
964
+ columns: list[list[AspectModel]] = []
965
+ column_capacities: list[int] = []
966
+
967
+ for aspect in typed_aspects_list:
968
+ if not columns or len(columns[-1]) >= column_capacities[-1]:
969
+ new_col_index = len(columns)
970
+ capacity = aspects_per_column if new_col_index < full_height_column_index else full_height_capacity
971
+ capacity = max(capacity, 1)
972
+ columns.append([])
973
+ column_capacities.append(capacity)
974
+ columns[-1].append(aspect)
975
+
976
+ for col_idx, column in enumerate(columns):
977
+ capacity = column_capacities[col_idx]
978
+ horizontal_position = col_idx * column_width
979
+ column_len = len(column)
980
+
981
+ for row_idx, aspect in enumerate(column):
982
+ # Default top-aligned placement
983
+ vertical_position = row_idx * line_height
984
+
985
+ # Full-height columns reuse the shared baseline so every column
986
+ # finishes at the same vertical position and grows upwards.
987
+ if col_idx >= full_height_column_index:
988
+ vertical_index = baseline_index - (column_len - 1 - row_idx)
989
+ vertical_position = vertical_index * line_height
990
+ # Legacy overflow columns (before the 12th) keep the older behaviour:
991
+ # once we exceed the configured column count, bottom-align the content
992
+ # so the shorter columns do not look awkwardly padded at the top.
993
+ elif col_idx >= max_columns and capacity == aspects_per_column:
994
+ top_offset_lines = max(0, capacity - len(column))
995
+ vertical_position = (top_offset_lines + row_idx) * line_height
996
+
997
+ inner_path += f'<g transform="translate({horizontal_position},{vertical_position})">'
998
+
999
+ # First planet symbol
1000
+ inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p1"]]["name"]}" />'
1001
+
1002
+ # Aspect symbol
1003
+ aspect_name = aspect["aspect"]
1004
+ id_value = next((a["degree"] for a in aspects_settings if a["name"] == aspect_name), None) # type: ignore
1005
+ inner_path += f'<use x="15" y="0" xlink:href="#orb{id_value}" />'
1006
+
1007
+ # Second planet symbol
1008
+ inner_path += '<g transform="translate(30,0)">'
1009
+ inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p2"]]["name"]}" />'
1010
+ inner_path += "</g>"
1011
+
1012
+ # Difference in degrees
1013
+ inner_path += f'<text y="8" x="45" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 10px;">{convert_decimal_to_degree_string(aspect["orbit"])}</text>'
1014
+
1015
+ inner_path += "</g>"
1016
+
1017
+ out = f'<g transform="translate({translate_x},{translate_y})">'
738
1018
  out += f'<text y="-15" x="0" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 14px;">{grid_title}:</text>'
739
1019
  out += inner_path
740
- out += '</g>'
1020
+ out += "</g>"
741
1021
 
742
1022
  return out
743
1023
 
@@ -804,31 +1084,28 @@ def calculate_moon_phase_chart_params(
804
1084
  "lunar_phase_rotate": lunar_phase_rotate,
805
1085
  }
806
1086
 
807
- def draw_house_grid(
1087
+
1088
+ def draw_main_house_grid(
808
1089
  main_subject_houses_list: list[KerykeionPointModel],
809
- chart_type: ChartType,
810
- secondary_subject_houses_list: Union[list[KerykeionPointModel], None] = None,
811
- text_color: str = "#000000",
812
1090
  house_cusp_generale_name_label: str = "Cusp",
1091
+ text_color: str = "#000000",
1092
+ x_position: int = 750,
1093
+ y_position: int = 30,
813
1094
  ) -> str:
814
1095
  """
815
- Generate SVG code for a grid of astrological houses.
1096
+ Generate SVG code for a grid of astrological houses for the main subject.
816
1097
 
817
1098
  Parameters:
818
- - main_houses (list[KerykeionPointModel]): List of houses for the main subject.
819
- - chart_type (ChartType): Type of the chart (e.g., Synastry, Transit).
820
- - secondary_houses (list[KerykeionPointModel], optional): List of houses for the secondary subject.
821
- - text_color (str): Color of the text.
822
- - cusp_label (str): Label for the house cusp.
1099
+ - main_subject_houses_list (list[KerykeionPointModel]): List of houses for the main subject.
1100
+ - house_cusp_generale_name_label (str): Label for the house cusp. Defaults to "Cusp".
1101
+ - text_color (str): Color of the text. Defaults to "#000000".
1102
+ - x_position (int): X position for the grid. Defaults to 720.
1103
+ - y_position (int): Y position for the grid. Defaults to 30.
823
1104
 
824
1105
  Returns:
825
1106
  - str: The SVG code for the grid of houses.
826
1107
  """
827
-
828
- if chart_type in ["Synastry", "Transit"] and secondary_subject_houses_list is None:
829
- raise KerykeionException("secondary_houses is None")
830
-
831
- svg_output = '<g transform="translate(650,-20)">'
1108
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
832
1109
 
833
1110
  line_increment = 10
834
1111
  for i, house in enumerate(main_subject_houses_list):
@@ -843,74 +1120,108 @@ def draw_house_grid(
843
1120
  line_increment += 14
844
1121
 
845
1122
  svg_output += "</g>"
1123
+ return svg_output
846
1124
 
847
- if chart_type == "Synastry":
848
- svg_output += '<!-- Synastry Houses -->'
849
- svg_output += '<g transform="translate(910, -20)">'
850
- line_increment = 10
851
-
852
- for i, house in enumerate(secondary_subject_houses_list): # type: ignore
853
- cusp_number = f"&#160;&#160;{i + 1}" if i < 9 else str(i + 1)
854
- svg_output += (
855
- f'<g transform="translate(0,{line_increment})">'
856
- f'<text text-anchor="end" x="40" style="fill:{text_color}; font-size: 10px;">{house_cusp_generale_name_label} {cusp_number}:</text>'
857
- f'<g transform="translate(40,-8)"><use transform="scale(0.3)" xlink:href="#{house["sign"]}" /></g>'
858
- f'<text x="53" style="fill:{text_color}; font-size: 10px;"> {convert_decimal_to_degree_string(house["position"])}</text>'
859
- f'</g>'
860
- )
861
- line_increment += 14
862
1125
 
863
- svg_output += "</g>"
1126
+ def draw_secondary_house_grid(
1127
+ secondary_subject_houses_list: list[KerykeionPointModel],
1128
+ house_cusp_generale_name_label: str = "Cusp",
1129
+ text_color: str = "#000000",
1130
+ x_position: int = 1015,
1131
+ y_position: int = 30,
1132
+ ) -> str:
1133
+ """
1134
+ Generate SVG code for a grid of astrological houses for the secondary subject.
1135
+
1136
+ Parameters:
1137
+ - secondary_subject_houses_list (list[KerykeionPointModel]): List of houses for the secondary subject.
1138
+ - house_cusp_generale_name_label (str): Label for the house cusp. Defaults to "Cusp".
1139
+ - text_color (str): Color of the text. Defaults to "#000000".
1140
+ - x_position (int): X position for the grid. Defaults to 970.
1141
+ - y_position (int): Y position for the grid. Defaults to 30.
864
1142
 
1143
+ Returns:
1144
+ - str: The SVG code for the grid of houses.
1145
+ """
1146
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1147
+
1148
+ line_increment = 10
1149
+ for i, house in enumerate(secondary_subject_houses_list):
1150
+ cusp_number = f"&#160;&#160;{i + 1}" if i < 9 else str(i + 1)
1151
+ svg_output += (
1152
+ f'<g transform="translate(0,{line_increment})">'
1153
+ f'<text text-anchor="end" x="40" style="fill:{text_color}; font-size: 10px;">{house_cusp_generale_name_label} {cusp_number}:</text>'
1154
+ f'<g transform="translate(40,-8)"><use transform="scale(0.3)" xlink:href="#{house["sign"]}" /></g>'
1155
+ f'<text x="53" style="fill:{text_color}; font-size: 10px;"> {convert_decimal_to_degree_string(house["position"])}</text>'
1156
+ f'</g>'
1157
+ )
1158
+ line_increment += 14
1159
+
1160
+ svg_output += "</g>"
865
1161
  return svg_output
866
1162
 
867
1163
 
868
- def draw_planet_grid(
1164
+ def draw_main_planet_grid(
869
1165
  planets_and_houses_grid_title: str,
870
1166
  subject_name: str,
871
1167
  available_kerykeion_celestial_points: list[KerykeionPointModel],
872
1168
  chart_type: ChartType,
873
1169
  celestial_point_language: KerykeionLanguageCelestialPointModel,
874
- second_subject_name: Union[str, None] = None,
875
- second_subject_available_kerykeion_celestial_points: Union[list[KerykeionPointModel], None] = None,
876
1170
  text_color: str = "#000000",
1171
+ x_position: int = 645,
1172
+ y_position: int = 0,
877
1173
  ) -> str:
878
1174
  """
879
- Draws the planet grid for the given celestial points and chart type.
1175
+ Draw the planet grid (main subject) and optional title.
1176
+
1177
+ The entire output is wrapped in a single SVG group `<g>` so the
1178
+ whole block can be repositioned by changing the group transform.
880
1179
 
881
1180
  Args:
882
- planets_and_houses_grid_title (str): Title of the grid.
883
- subject_name (str): Name of the subject.
884
- available_kerykeion_celestial_points (list[KerykeionPointModel]): List of celestial points for the subject.
885
- chart_type (ChartType): Type of the chart.
886
- celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points.
887
- second_subject_name (str, optional): Name of the second subject. Defaults to None.
888
- second_subject_available_kerykeion_celestial_points (list[KerykeionPointModel], optional): List of celestial points for the second subject. Defaults to None.
889
- text_color (str, optional): Color of the text. Defaults to "#000000".
1181
+ planets_and_houses_grid_title: Title prefix to show for eligible chart types.
1182
+ subject_name: Subject name to append to the title.
1183
+ available_kerykeion_celestial_points: Celestial points to render in the grid.
1184
+ chart_type: Chart type identifier (Literal string).
1185
+ celestial_point_language: Language model for celestial point decoding.
1186
+ text_color: Text color for labels (default: "#000000").
1187
+ x_position: X translation applied to the outer `<g>` (default: 620).
1188
+ y_position: Y translation applied to the outer `<g>` (default: 0).
890
1189
 
891
1190
  Returns:
892
- str: The SVG output for the planet grid.
1191
+ SVG string for the main planet grid wrapped in a `<g>`.
893
1192
  """
894
- line_height = 10
895
- offset = 0
896
- offset_between_lines = 14
1193
+ # Layout constants (kept identical to previous behavior)
1194
+ BASE_Y = 30
1195
+ HEADER_Y = 15 # Title baseline inside the wrapper
1196
+ LINE_START = 10
1197
+ LINE_STEP = 14
897
1198
 
898
- svg_output = (
899
- f'<g transform="translate(175, -15)">'
900
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}:</text>'
901
- f'</g>'
902
- )
1199
+ # Wrap everything inside a single group so position can be changed once
1200
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1201
+
1202
+ # Add title only for specific chart types
1203
+ if chart_type in ("Synastry", "Transit", "DualReturnChart"):
1204
+ svg_output += (
1205
+ f'<g transform="translate(0, {HEADER_Y})">'
1206
+ f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}</text>'
1207
+ f'</g>'
1208
+ )
903
1209
 
904
1210
  end_of_line = "</g>"
905
1211
 
1212
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1213
+
906
1214
  for i, planet in enumerate(available_kerykeion_celestial_points):
907
- if i == 27:
908
- line_height = 10
909
- offset = -120
1215
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1216
+ line_height = LINE_START + (row_index * LINE_STEP)
1217
+
1218
+ decoded_name = get_decoded_kerykeion_celestial_point_name(
1219
+ planet["name"],
1220
+ celestial_point_language,
1221
+ )
910
1222
 
911
- decoded_name = get_decoded_kerykeion_celestial_point_name(planet["name"], celestial_point_language)
912
1223
  svg_output += (
913
- f'<g transform="translate({offset},{line_height})">'
1224
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
914
1225
  f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{decoded_name}</text>'
915
1226
  f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{planet["name"]}" /></g>'
916
1227
  f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(planet["position"])}</text>'
@@ -921,47 +1232,94 @@ def draw_planet_grid(
921
1232
  svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
922
1233
 
923
1234
  svg_output += end_of_line
924
- line_height += offset_between_lines
925
1235
 
926
- if chart_type in ["Transit", "Synastry"]:
927
- if second_subject_available_kerykeion_celestial_points is None:
928
- raise KerykeionException("second_subject_available_kerykeion_celestial_points is None")
1236
+ # Close the wrapper group
1237
+ svg_output += "</g>"
929
1238
 
930
- if chart_type == "Transit":
931
- svg_output += (
932
- f'<g transform="translate(320, -15)">'
933
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 14px;">{second_subject_name}:</text>'
934
- )
935
- else:
936
- svg_output += (
937
- f'<g transform="translate(380, -15)">'
938
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {second_subject_name}:</text>'
939
- )
1239
+ return svg_output
940
1240
 
941
- svg_output += end_of_line
942
1241
 
943
- second_line_height = 10
944
- second_offset = 250
945
-
946
- for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
947
- if i == 27:
948
- second_line_height = 10
949
- second_offset = -120
950
-
951
- second_decoded_name = get_decoded_kerykeion_celestial_point_name(t_planet["name"], celestial_point_language)
952
- svg_output += (
953
- f'<g transform="translate({second_offset},{second_line_height})">'
954
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{second_decoded_name}</text>'
955
- f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{t_planet["name"]}" /></g>'
956
- f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(t_planet["position"])}</text>'
957
- f'<g transform="translate(60,-8)"><use transform="scale(0.3)" xlink:href="#{t_planet["sign"]}" /></g>'
958
- )
1242
+ def draw_secondary_planet_grid(
1243
+ planets_and_houses_grid_title: str,
1244
+ second_subject_name: str,
1245
+ second_subject_available_kerykeion_celestial_points: list[KerykeionPointModel],
1246
+ chart_type: ChartType,
1247
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1248
+ text_color: str = "#000000",
1249
+ x_position: int = 910,
1250
+ y_position: int = 0,
1251
+ ) -> str:
1252
+ """
1253
+ Draw the planet grid for the secondary subject and its title.
959
1254
 
960
- if t_planet["retrograde"]:
961
- svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1255
+ The entire output is wrapped in a single SVG group `<g>` so the
1256
+ whole block can be repositioned by changing the group transform.
962
1257
 
963
- svg_output += end_of_line
964
- second_line_height += offset_between_lines
1258
+ Args:
1259
+ planets_and_houses_grid_title: Title prefix (used except for Transit charts).
1260
+ second_subject_name: Name of the secondary subject.
1261
+ second_subject_available_kerykeion_celestial_points: Celestial points to render for the secondary subject.
1262
+ chart_type: Chart type identifier (Literal string).
1263
+ celestial_point_language: Language model for celestial point decoding.
1264
+ text_color: Text color for labels (default: "#000000").
1265
+ x_position: X translation applied to the outer `<g>` (default: 870).
1266
+ y_position: Y translation applied to the outer `<g>` (default: 0).
1267
+
1268
+ Returns:
1269
+ SVG string for the secondary planet grid wrapped in a `<g>`.
1270
+ """
1271
+ # Layout constants
1272
+ BASE_Y = 30
1273
+ HEADER_Y = 15
1274
+ LINE_START = 10
1275
+ LINE_STEP = 14
1276
+
1277
+ # Open wrapper group
1278
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1279
+
1280
+ # Title content and its relative x offset
1281
+ header_text = (
1282
+ second_subject_name if chart_type == "Transit"
1283
+ else f"{planets_and_houses_grid_title} {second_subject_name}"
1284
+ )
1285
+ header_x_offset = -50 if chart_type == "Transit" else 0
1286
+
1287
+ svg_output += (
1288
+ f'<g transform="translate({header_x_offset}, {HEADER_Y})">'
1289
+ f'<text style="fill:{text_color}; font-size: 14px;">{header_text}</text>'
1290
+ f'</g>'
1291
+ )
1292
+
1293
+ # Grid rows
1294
+ line_height = LINE_START
1295
+ end_of_line = "</g>"
1296
+
1297
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1298
+
1299
+ for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
1300
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1301
+ line_height = LINE_START + (row_index * LINE_STEP)
1302
+
1303
+ second_decoded_name = get_decoded_kerykeion_celestial_point_name(
1304
+ t_planet["name"],
1305
+ celestial_point_language,
1306
+ )
1307
+ svg_output += (
1308
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
1309
+ f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{second_decoded_name}</text>'
1310
+ f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{t_planet["name"]}" /></g>'
1311
+ f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(t_planet["position"])}</text>'
1312
+ f'<g transform="translate(60,-8)"><use transform="scale(0.3)" xlink:href="#{t_planet["sign"]}" /></g>'
1313
+ )
1314
+
1315
+ if t_planet["retrograde"]:
1316
+ svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1317
+
1318
+ svg_output += end_of_line
1319
+
1320
+
1321
+ # Close wrapper group
1322
+ svg_output += "</g>"
965
1323
 
966
1324
  return svg_output
967
1325
 
@@ -994,7 +1352,7 @@ def draw_transit_aspect_grid(
994
1352
  y_start = y_indent
995
1353
 
996
1354
  # Filter active planets
997
- active_planets = [planet for planet in available_planets if planet.is_active]
1355
+ active_planets = [planet for planet in available_planets if planet["is_active"]]
998
1356
 
999
1357
  # Reverse the list of active planets for the first iteration
1000
1358
  reversed_planets = active_planets[::-1]
@@ -1040,3 +1398,416 @@ def draw_transit_aspect_grid(
1040
1398
  svg_output += f'<use x="{x_aspect - box_size + 1}" y="{y_aspect + 1}" xlink:href="#orb{aspect["aspect_degrees"]}" />'
1041
1399
 
1042
1400
  return svg_output
1401
+
1402
+
1403
+ def format_location_string(location: str, max_length: int = 35) -> str:
1404
+ """
1405
+ Format a location string to ensure it fits within a specified maximum length.
1406
+
1407
+ If the location is longer than max_length, it attempts to shorten by using only
1408
+ the first and last parts separated by commas. If still too long, it truncates
1409
+ and adds ellipsis.
1410
+
1411
+ Args:
1412
+ location: The original location string
1413
+ max_length: Maximum allowed length for the output string (default: 35)
1414
+
1415
+ Returns:
1416
+ Formatted location string that fits within max_length
1417
+ """
1418
+ if len(location) > max_length:
1419
+ split_location = location.split(",")
1420
+ if len(split_location) > 1:
1421
+ shortened = split_location[0] + ", " + split_location[-1]
1422
+ if len(shortened) > max_length:
1423
+ return shortened[:max_length] + "..."
1424
+ return shortened
1425
+ else:
1426
+ return location[:max_length] + "..."
1427
+ return location
1428
+
1429
+
1430
+ def format_datetime_with_timezone(iso_datetime_string: str) -> str:
1431
+ """
1432
+ Format an ISO datetime string with a custom format that includes properly formatted timezone.
1433
+
1434
+ Args:
1435
+ iso_datetime_string: ISO formatted datetime string
1436
+
1437
+ Returns:
1438
+ Formatted datetime string with properly formatted timezone offset (HH:MM)
1439
+ """
1440
+ dt = datetime.datetime.fromisoformat(iso_datetime_string)
1441
+ custom_format = dt.strftime('%Y-%m-%d %H:%M [%z]')
1442
+ custom_format = custom_format[:-3] + ':' + custom_format[-3:]
1443
+
1444
+ return custom_format
1445
+
1446
+
1447
+ def calculate_element_points(
1448
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1449
+ celestial_points_names: Sequence[str],
1450
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1451
+ *,
1452
+ method: ElementQualityDistributionMethod = "weighted",
1453
+ custom_weights: Optional[Mapping[str, float]] = None,
1454
+ ) -> dict[str, float]:
1455
+ """
1456
+ Calculate elemental totals for a subject using the selected strategy.
1457
+
1458
+ Args:
1459
+ planets_settings: Planet configuration list (kept for API compatibility).
1460
+ celestial_points_names: Celestial point names to include.
1461
+ subject: Astrological subject with planetary data.
1462
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1463
+ custom_weights: Optional overrides for point weights keyed by name.
1464
+
1465
+ Returns:
1466
+ Dictionary mapping each element to its accumulated total.
1467
+ """
1468
+ normalized_names = [name.lower() for name in celestial_points_names]
1469
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1470
+
1471
+ return _calculate_distribution_for_subject(
1472
+ subject,
1473
+ normalized_names,
1474
+ _SIGN_TO_ELEMENT,
1475
+ _ELEMENT_KEYS,
1476
+ weight_lookup,
1477
+ fallback_weight,
1478
+ )
1479
+
1480
+
1481
+ def calculate_synastry_element_points(
1482
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1483
+ celestial_points_names: Sequence[str],
1484
+ subject1: AstrologicalSubjectModel,
1485
+ subject2: AstrologicalSubjectModel,
1486
+ *,
1487
+ method: ElementQualityDistributionMethod = "weighted",
1488
+ custom_weights: Optional[Mapping[str, float]] = None,
1489
+ ) -> dict[str, float]:
1490
+ """
1491
+ Calculate combined element percentages for a synastry chart.
1492
+
1493
+ Args:
1494
+ planets_settings: Planet configuration list (unused but preserved).
1495
+ celestial_points_names: Celestial point names to process.
1496
+ subject1: First astrological subject.
1497
+ subject2: Second astrological subject.
1498
+ method: Calculation strategy (pure_count or weighted).
1499
+ custom_weights: Optional overrides for point weights.
1500
+
1501
+ Returns:
1502
+ Dictionary with element percentages summing to 100.
1503
+ """
1504
+ normalized_names = [name.lower() for name in celestial_points_names]
1505
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1506
+
1507
+ subject1_totals = _calculate_distribution_for_subject(
1508
+ subject1,
1509
+ normalized_names,
1510
+ _SIGN_TO_ELEMENT,
1511
+ _ELEMENT_KEYS,
1512
+ weight_lookup,
1513
+ fallback_weight,
1514
+ )
1515
+ subject2_totals = _calculate_distribution_for_subject(
1516
+ subject2,
1517
+ normalized_names,
1518
+ _SIGN_TO_ELEMENT,
1519
+ _ELEMENT_KEYS,
1520
+ weight_lookup,
1521
+ fallback_weight,
1522
+ )
1523
+
1524
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _ELEMENT_KEYS}
1525
+ total_points = sum(combined_totals.values())
1526
+
1527
+ if total_points == 0:
1528
+ return {key: 0.0 for key in _ELEMENT_KEYS}
1529
+
1530
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _ELEMENT_KEYS}
1531
+
1532
+
1533
+ def draw_house_comparison_grid(
1534
+ house_comparison: "HouseComparisonModel",
1535
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1536
+ active_points: list[AstrologicalPoint],
1537
+ *,
1538
+ points_owner_subject_number: Literal[1, 2] = 1,
1539
+ text_color: str = "var(--kerykeion-color-neutral-content)",
1540
+ house_position_comparison_label: str = "House Position Comparison",
1541
+ return_point_label: str = "Return Point",
1542
+ return_label: str = "DualReturnChart",
1543
+ radix_label: str = "Radix",
1544
+ x_position: int = 1100,
1545
+ y_position: int = 0,
1546
+ ) -> str:
1547
+ """
1548
+ Generate SVG code for displaying a comparison of points across houses between two charts.
1549
+
1550
+ Parameters:
1551
+ - house_comparison ("HouseComparisonModel"): Model containing house comparison data,
1552
+ including first_subject_name, second_subject_name, and points in houses.
1553
+ - celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points
1554
+ - active_celestial_points (list[KerykeionPointModel]): List of active celestial points to display
1555
+ - text_color (str): Color for the text elements
1556
+
1557
+ Returns:
1558
+ - str: SVG code for the house comparison grid.
1559
+ """
1560
+ if points_owner_subject_number == 1:
1561
+ comparison_data = house_comparison.first_points_in_second_houses
1562
+ else:
1563
+ comparison_data = house_comparison.second_points_in_first_houses
1564
+
1565
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1566
+
1567
+ # Add title
1568
+ svg_output += f'<text text-anchor="start" x="0" y="-15" style="fill:{text_color}; font-size: 14px;">{house_position_comparison_label}</text>'
1569
+
1570
+ # Add column headers
1571
+ line_increment = 10
1572
+ svg_output += (
1573
+ f'<g transform="translate(0,{line_increment})">'
1574
+ f'<text text-anchor="start" x="0" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{return_point_label}</text>'
1575
+ f'<text text-anchor="start" x="77" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{return_label}</text>'
1576
+ f'<text text-anchor="start" x="132" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{radix_label}</text>'
1577
+ f'</g>'
1578
+ )
1579
+ line_increment += 15
1580
+
1581
+ # Create a dictionary to store all points by name for combined display
1582
+ all_points_by_name = {}
1583
+
1584
+ for point in comparison_data:
1585
+ # Only process points that are active
1586
+ if point.point_name in active_points and point.point_name not in all_points_by_name:
1587
+ all_points_by_name[point.point_name] = {
1588
+ "name": point.point_name,
1589
+ "secondary_house": point.projected_house_number,
1590
+ "native_house": point.point_owner_house_number
1591
+ }
1592
+
1593
+ # Display all points organized by name
1594
+ for name, point_data in all_points_by_name.items():
1595
+ native_house = point_data.get("native_house", "-")
1596
+ secondary_house = point_data.get("secondary_house", "-")
1597
+
1598
+ svg_output += (
1599
+ f'<g transform="translate(0,{line_increment})">'
1600
+ f'<g transform="translate(0,-9)"><use transform="scale(0.4)" xlink:href="#{name}" /></g>'
1601
+ f'<text text-anchor="start" x="15" style="fill:{text_color}; font-size: 10px;">{get_decoded_kerykeion_celestial_point_name(name, celestial_point_language)}</text>'
1602
+ f'<text text-anchor="start" x="90" style="fill:{text_color}; font-size: 10px;">{native_house}</text>'
1603
+ f'<text text-anchor="start" x="140" style="fill:{text_color}; font-size: 10px;">{secondary_house}</text>'
1604
+ f'</g>'
1605
+ )
1606
+ line_increment += 12
1607
+
1608
+ svg_output += "</g>"
1609
+
1610
+ return svg_output
1611
+
1612
+
1613
+ def draw_single_house_comparison_grid(
1614
+ house_comparison: "HouseComparisonModel",
1615
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1616
+ active_points: list[AstrologicalPoint],
1617
+ *,
1618
+ points_owner_subject_number: Literal[1, 2] = 1,
1619
+ text_color: str = "var(--kerykeion-color-neutral-content)",
1620
+ house_position_comparison_label: str = "House Position Comparison",
1621
+ return_point_label: str = "Return Point",
1622
+ natal_house_label: str = "Natal House",
1623
+ x_position: int = 1030,
1624
+ y_position: int = 0,
1625
+ ) -> str:
1626
+ """
1627
+ Generate SVG code for displaying celestial points and their house positions.
1628
+
1629
+ Parameters:
1630
+ - house_comparison ("HouseComparisonModel"): Model containing house comparison data,
1631
+ including first_subject_name, second_subject_name, and points in houses.
1632
+ - celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points
1633
+ - active_points (list[AstrologicalPoint]): List of active celestial points to display
1634
+ - points_owner_subject_number (Literal[1, 2]): Which subject's points to display (1 for first, 2 for second)
1635
+ - text_color (str): Color for the text elements
1636
+ - house_position_comparison_label (str): Label for the house position comparison grid
1637
+ - return_point_label (str): Label for the return point column
1638
+ - house_position_label (str): Label for the house position column
1639
+ - x_position (int): X position for the grid
1640
+ - y_position (int): Y position for the grid
1641
+
1642
+ Returns:
1643
+ - str: SVG code for the house position grid.
1644
+ """
1645
+ if points_owner_subject_number == 1:
1646
+ comparison_data = house_comparison.first_points_in_second_houses
1647
+ else:
1648
+ comparison_data = house_comparison.second_points_in_first_houses
1649
+
1650
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1651
+
1652
+ # Add title
1653
+ svg_output += f'<text text-anchor="start" x="0" y="-15" style="fill:{text_color}; font-size: 14px;">{house_position_comparison_label}</text>'
1654
+
1655
+ # Add column headers
1656
+ line_increment = 10
1657
+ svg_output += (
1658
+ f'<g transform="translate(0,{line_increment})">'
1659
+ f'<text text-anchor="start" x="0" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{return_point_label}</text>'
1660
+ f'<text text-anchor="start" x="77" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{natal_house_label}</text>'
1661
+ f'</g>'
1662
+ )
1663
+ line_increment += 15
1664
+
1665
+ # Create a dictionary to store all points by name for combined display
1666
+ all_points_by_name = {}
1667
+
1668
+ for point in comparison_data:
1669
+ # Only process points that are active
1670
+ if point.point_name in active_points and point.point_name not in all_points_by_name:
1671
+ all_points_by_name[point.point_name] = {
1672
+ "name": point.point_name,
1673
+ "house": point.projected_house_number
1674
+ }
1675
+
1676
+ # Display all points organized by name
1677
+ for name, point_data in all_points_by_name.items():
1678
+ house = point_data.get("house", "-")
1679
+
1680
+ svg_output += (
1681
+ f'<g transform="translate(0,{line_increment})">'
1682
+ f'<g transform="translate(0,-9)"><use transform="scale(0.4)" xlink:href="#{name}" /></g>'
1683
+ f'<text text-anchor="start" x="15" style="fill:{text_color}; font-size: 10px;">{get_decoded_kerykeion_celestial_point_name(name, celestial_point_language)}</text>'
1684
+ f'<text text-anchor="start" x="90" style="fill:{text_color}; font-size: 10px;">{house}</text>'
1685
+ f'</g>'
1686
+ )
1687
+ line_increment += 12
1688
+
1689
+ svg_output += "</g>"
1690
+
1691
+ return svg_output
1692
+
1693
+
1694
+ def makeLunarPhase(degrees_between_sun_and_moon: float, latitude: float) -> str:
1695
+ """
1696
+ Generate SVG representation of lunar phase.
1697
+
1698
+ Parameters:
1699
+ - degrees_between_sun_and_moon (float): Angle between sun and moon in degrees
1700
+ - latitude (float): Observer's latitude for correct orientation
1701
+
1702
+ Returns:
1703
+ - str: SVG representation of lunar phase
1704
+ """
1705
+ # Calculate parameters for the lunar phase visualization
1706
+ params = calculate_moon_phase_chart_params(degrees_between_sun_and_moon, latitude)
1707
+
1708
+ # Extract the calculated values
1709
+ lunar_phase_circle_center_x = params["circle_center_x"]
1710
+ lunar_phase_circle_radius = params["circle_radius"]
1711
+ lunar_phase_rotate = params["lunar_phase_rotate"]
1712
+
1713
+ # Generate the SVG for the lunar phase
1714
+ svg = (
1715
+ f'<g transform="rotate({lunar_phase_rotate} 20 10)">\n'
1716
+ f' <defs>\n'
1717
+ f' <clipPath id="moonPhaseCutOffCircle">\n'
1718
+ f' <circle cx="20" cy="10" r="10" />\n'
1719
+ f' </clipPath>\n'
1720
+ f' </defs>\n'
1721
+ f' <circle cx="20" cy="10" r="10" style="fill: var(--kerykeion-chart-color-lunar-phase-0)" />\n'
1722
+ f' <circle cx="{lunar_phase_circle_center_x}" cy="10" r="{lunar_phase_circle_radius}" style="fill: var(--kerykeion-chart-color-lunar-phase-1)" clip-path="url(#moonPhaseCutOffCircle)" />\n'
1723
+ f' <circle cx="20" cy="10" r="10" style="fill: none; stroke: var(--kerykeion-chart-color-lunar-phase-0); stroke-width: 0.5px; stroke-opacity: 0.5" />\n'
1724
+ f'</g>'
1725
+ )
1726
+
1727
+ return svg
1728
+
1729
+
1730
+ def calculate_quality_points(
1731
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1732
+ celestial_points_names: Sequence[str],
1733
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1734
+ *,
1735
+ method: ElementQualityDistributionMethod = "weighted",
1736
+ custom_weights: Optional[Mapping[str, float]] = None,
1737
+ ) -> dict[str, float]:
1738
+ """
1739
+ Calculate modality totals for a subject using the selected strategy.
1740
+
1741
+ Args:
1742
+ planets_settings: Planet configuration list (kept for API compatibility).
1743
+ celestial_points_names: Celestial point names to include.
1744
+ subject: Astrological subject with planetary data.
1745
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1746
+ custom_weights: Optional overrides for point weights keyed by name.
1747
+
1748
+ Returns:
1749
+ Dictionary mapping each modality to its accumulated total.
1750
+ """
1751
+ normalized_names = [name.lower() for name in celestial_points_names]
1752
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1753
+
1754
+ return _calculate_distribution_for_subject(
1755
+ subject,
1756
+ normalized_names,
1757
+ _SIGN_TO_QUALITY,
1758
+ _QUALITY_KEYS,
1759
+ weight_lookup,
1760
+ fallback_weight,
1761
+ )
1762
+
1763
+
1764
+ def calculate_synastry_quality_points(
1765
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1766
+ celestial_points_names: Sequence[str],
1767
+ subject1: AstrologicalSubjectModel,
1768
+ subject2: AstrologicalSubjectModel,
1769
+ *,
1770
+ method: ElementQualityDistributionMethod = "weighted",
1771
+ custom_weights: Optional[Mapping[str, float]] = None,
1772
+ ) -> dict[str, float]:
1773
+ """
1774
+ Calculate combined modality percentages for a synastry chart.
1775
+
1776
+ Args:
1777
+ planets_settings: Planet configuration list (unused but preserved).
1778
+ celestial_points_names: Celestial point names to process.
1779
+ subject1: First astrological subject.
1780
+ subject2: Second astrological subject.
1781
+ method: Calculation strategy (pure_count or weighted).
1782
+ custom_weights: Optional overrides for point weights.
1783
+
1784
+ Returns:
1785
+ Dictionary with modality percentages summing to 100.
1786
+ """
1787
+ normalized_names = [name.lower() for name in celestial_points_names]
1788
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1789
+
1790
+ subject1_totals = _calculate_distribution_for_subject(
1791
+ subject1,
1792
+ normalized_names,
1793
+ _SIGN_TO_QUALITY,
1794
+ _QUALITY_KEYS,
1795
+ weight_lookup,
1796
+ fallback_weight,
1797
+ )
1798
+ subject2_totals = _calculate_distribution_for_subject(
1799
+ subject2,
1800
+ normalized_names,
1801
+ _SIGN_TO_QUALITY,
1802
+ _QUALITY_KEYS,
1803
+ weight_lookup,
1804
+ fallback_weight,
1805
+ )
1806
+
1807
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _QUALITY_KEYS}
1808
+ total_points = sum(combined_totals.values())
1809
+
1810
+ if total_points == 0:
1811
+ return {key: 0.0 for key in _QUALITY_KEYS}
1812
+
1813
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _QUALITY_KEYS}