kerykeion 4.18.3__py3-none-any.whl → 5.1.9__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 +56 -11
  2. kerykeion/aspects/__init__.py +7 -4
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +86 -13
  5. kerykeion/astrological_subject_factory.py +1901 -0
  6. kerykeion/backword.py +820 -0
  7. kerykeion/chart_data_factory.py +552 -0
  8. kerykeion/charts/__init__.py +2 -2
  9. kerykeion/charts/chart_drawer.py +2794 -0
  10. kerykeion/charts/charts_utils.py +1066 -309
  11. kerykeion/charts/draw_planets.py +602 -351
  12. kerykeion/charts/templates/aspect_grid_only.xml +337 -193
  13. kerykeion/charts/templates/chart.xml +441 -240
  14. kerykeion/charts/templates/wheel_only.xml +365 -211
  15. kerykeion/charts/themes/black-and-white.css +148 -0
  16. kerykeion/charts/themes/classic.css +107 -76
  17. kerykeion/charts/themes/dark-high-contrast.css +145 -107
  18. kerykeion/charts/themes/dark.css +146 -107
  19. kerykeion/charts/themes/light.css +146 -103
  20. kerykeion/charts/themes/strawberry.css +158 -0
  21. kerykeion/composite_subject_factory.py +408 -0
  22. kerykeion/ephemeris_data_factory.py +443 -0
  23. kerykeion/fetch_geonames.py +81 -21
  24. kerykeion/house_comparison/__init__.py +6 -0
  25. kerykeion/house_comparison/house_comparison_factory.py +103 -0
  26. kerykeion/house_comparison/house_comparison_utils.py +126 -0
  27. kerykeion/kr_types/__init__.py +66 -6
  28. kerykeion/kr_types/chart_template_model.py +20 -0
  29. kerykeion/kr_types/kerykeion_exception.py +15 -9
  30. kerykeion/kr_types/kr_literals.py +14 -106
  31. kerykeion/kr_types/kr_models.py +14 -179
  32. kerykeion/kr_types/settings_models.py +15 -152
  33. kerykeion/planetary_return_factory.py +805 -0
  34. kerykeion/relationship_score_factory.py +301 -0
  35. kerykeion/report.py +750 -65
  36. kerykeion/schemas/__init__.py +106 -0
  37. kerykeion/schemas/chart_template_model.py +367 -0
  38. kerykeion/schemas/kerykeion_exception.py +20 -0
  39. kerykeion/schemas/kr_literals.py +181 -0
  40. kerykeion/schemas/kr_models.py +603 -0
  41. kerykeion/schemas/settings_models.py +188 -0
  42. kerykeion/settings/__init__.py +20 -1
  43. kerykeion/settings/chart_defaults.py +444 -0
  44. kerykeion/settings/config_constants.py +152 -0
  45. kerykeion/settings/kerykeion_settings.py +36 -61
  46. kerykeion/settings/translation_strings.py +1499 -0
  47. kerykeion/settings/translations.py +74 -0
  48. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  50. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  51. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  52. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  54. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  55. kerykeion/sweph/sefstars.txt +1602 -0
  56. kerykeion/transits_time_range_factory.py +302 -0
  57. kerykeion/utilities.py +626 -125
  58. kerykeion-5.1.9.dist-info/METADATA +1793 -0
  59. kerykeion-5.1.9.dist-info/RECORD +63 -0
  60. {kerykeion-4.18.3.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -1
  61. kerykeion/aspects/natal_aspects.py +0 -143
  62. kerykeion/aspects/synastry_aspects.py +0 -113
  63. kerykeion/astrological_subject.py +0 -818
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -894
  65. kerykeion/enums.py +0 -51
  66. kerykeion/ephemeris_data.py +0 -178
  67. kerykeion/kr_types/chart_types.py +0 -88
  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 -275
  71. kerykeion/settings/kr.config.json +0 -721
  72. kerykeion-4.18.3.dist-info/LICENSE +0 -661
  73. kerykeion-4.18.3.dist-info/METADATA +0 -396
  74. kerykeion-4.18.3.dist-info/RECORD +0 -42
  75. kerykeion-4.18.3.dist-info/entry_points.txt +0 -3
  76. /LICENSE → /kerykeion-5.1.9.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:
@@ -18,13 +239,6 @@ def get_decoded_kerykeion_celestial_point_name(input_planet_name: str, celestial
18
239
  str: The decoded celestial point name.
19
240
  """
20
241
 
21
- # Dictionary for special house names
22
- special_house_names = {
23
- "First_House": "Asc",
24
- "Seventh_House": "Dsc",
25
- "Tenth_House": "Mc",
26
- "Fourth_House": "Ic"
27
- }
28
242
 
29
243
  # Get the language model keys
30
244
  language_keys = celestial_point_language.model_dump().keys()
@@ -32,9 +246,8 @@ def get_decoded_kerykeion_celestial_point_name(input_planet_name: str, celestial
32
246
  # Check if the input planet name exists in the language model
33
247
  if input_planet_name in language_keys:
34
248
  return celestial_point_language[input_planet_name]
35
-
36
- # Return the special house name if it exists, otherwise return an empty string
37
- return special_house_names.get(input_planet_name, "")
249
+ else:
250
+ raise KerykeionException(f"Celestial point {input_planet_name} not found in language model.")
38
251
 
39
252
 
40
253
  def decHourJoin(inH: int, inM: int, inS: int) -> float:
@@ -56,24 +269,42 @@ def decHourJoin(inH: int, inM: int, inS: int) -> float:
56
269
 
57
270
 
58
271
  def degreeDiff(a: Union[int, float], b: Union[int, float]) -> float:
59
- """Calculate the difference between two degrees.
272
+ """Calculate the smallest difference between two angles in degrees.
60
273
 
61
274
  Args:
62
- - a (int | float): first degree
63
- - b (int | float): second degree
275
+ a (int | float): first angle in degrees
276
+ b (int | float): second angle in degrees
64
277
 
65
278
  Returns:
66
- float: difference between a and b
279
+ float: smallest difference between a and b (0 to 180 degrees)
67
280
  """
281
+ diff = math.fmod(abs(a - b), 360) # Assicura che il valore sia in [0, 360)
282
+ return min(diff, 360 - diff) # Prende l'angolo più piccolo tra i due possibili
283
+
284
+
285
+ def degreeSum(a: Union[int, float], b: Union[int, float]) -> float:
286
+ """Calculate the sum of two angles in degrees, normalized to [0, 360).
287
+
288
+ Args:
289
+ a (int | float): first angle in degrees
290
+ b (int | float): second angle in degrees
291
+
292
+ Returns:
293
+ float: normalized sum of a and b in the range [0, 360)
294
+ """
295
+ return math.fmod(a + b, 360) if (a + b) % 360 != 0 else 0.0
68
296
 
69
- out = float()
70
- if a > b:
71
- out = a - b
72
- if a < b:
73
- out = b - a
74
- if out > 180.0:
75
- out = 360.0 - out
76
- return out
297
+
298
+ def normalizeDegree(angle: Union[int, float]) -> float:
299
+ """Normalize an angle to the range [0, 360).
300
+
301
+ Args:
302
+ angle (int | float): The input angle in degrees.
303
+
304
+ Returns:
305
+ float: The normalized angle in the range [0, 360).
306
+ """
307
+ return angle % 360 if angle % 360 != 0 else 0.0
77
308
 
78
309
 
79
310
  def offsetToTz(datetime_offset: Union[datetime.timedelta, None]) -> float:
@@ -176,7 +407,7 @@ def draw_zodiac_slice(
176
407
  # pie slices
177
408
  offset = 360 - seventh_house_degree_ut
178
409
  # check transit
179
- if chart_type == "Transit" or chart_type == "Synastry":
410
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
180
411
  dropin: Union[int, float] = 0
181
412
  else:
182
413
  dropin = c1
@@ -185,7 +416,7 @@ def draw_zodiac_slice(
185
416
  # symbols
186
417
  offset = offset + 15
187
418
  # check transit
188
- if chart_type == "Transit" or chart_type == "Synastry":
419
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
189
420
  dropin = 54
190
421
  else:
191
422
  dropin = 18 + c1
@@ -274,54 +505,12 @@ def draw_aspect_line(
274
505
  y2 = sliceToY(0, ar, second_offset) + (r - ar)
275
506
 
276
507
  return (
277
- 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"]}">'
278
509
  f'<line class="aspect" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {color}; stroke-width: 1; stroke-opacity: .9;"/>'
279
510
  f"</g>"
280
511
  )
281
512
 
282
513
 
283
- def draw_elements_percentages(
284
- fire_label: str,
285
- fire_points: float,
286
- earth_label: str,
287
- earth_points: float,
288
- air_label: str,
289
- air_points: float,
290
- water_label: str,
291
- water_points: float,
292
- ) -> str:
293
- """Draw the elements grid.
294
-
295
- Args:
296
- - fire_label (str): Label for fire
297
- - fire_points (float): Points for fire
298
- - earth_label (str): Label for earth
299
- - earth_points (float): Points for earth
300
- - air_label (str): Label for air
301
- - air_points (float): Points for air
302
- - water_label (str): Label for water
303
- - water_points (float): Points for water
304
-
305
- Returns:
306
- str: The SVG elements grid as a string.
307
- """
308
- total = fire_points + earth_points + air_points + water_points
309
-
310
- fire_percentage = int(round(100 * fire_points / total))
311
- earth_percentage = int(round(100 * earth_points / total))
312
- air_percentage = int(round(100 * air_points / total))
313
- water_percentage = int(round(100 * water_points / total))
314
-
315
- return (
316
- f'<g transform="translate(-30,79)">'
317
- f'<text y="0" style="fill: var(--kerykeion-chart-color-fire-percentage); font-size: 10px;">{fire_label} {str(fire_percentage)}%</text>'
318
- f'<text y="12" style="fill: var(--kerykeion-chart-color-earth-percentage); font-size: 10px;">{earth_label} {str(earth_percentage)}%</text>'
319
- f'<text y="24" style="fill: var(--kerykeion-chart-color-air-percentage); font-size: 10px;">{air_label} {str(air_percentage)}%</text>'
320
- f'<text y="36" style="fill: var(--kerykeion-chart-color-water-percentage); font-size: 10px;">{water_label} {str(water_percentage)}%</text>'
321
- f"</g>"
322
- )
323
-
324
-
325
514
  def convert_decimal_to_degree_string(dec: float, format_type: Literal["1", "2", "3"] = "3") -> str:
326
515
  """
327
516
  Converts a decimal float to a degrees string in the specified format.
@@ -448,7 +637,7 @@ def draw_first_circle(
448
637
  Returns:
449
638
  str: The SVG path of the first circle.
450
639
  """
451
- if chart_type == "Synastry" or chart_type == "Transit":
640
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
452
641
  return f'<circle cx="{r}" cy="{r}" r="{r - 36}" style="fill: none; stroke: {stroke_color}; stroke-width: 1px; stroke-opacity:.4;" />'
453
642
  else:
454
643
  if c1 is None:
@@ -459,6 +648,21 @@ def draw_first_circle(
459
648
  )
460
649
 
461
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
+
462
666
  def draw_second_circle(
463
667
  r: Union[int, float], stroke_color: str, fill_color: str, chart_type: ChartType, c2: Union[int, float, None] = None
464
668
  ) -> str:
@@ -476,7 +680,7 @@ def draw_second_circle(
476
680
  str: The SVG path of the second circle.
477
681
  """
478
682
 
479
- if chart_type == "Synastry" or chart_type == "Transit":
683
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
480
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" />'
481
685
 
482
686
  else:
@@ -506,7 +710,7 @@ def draw_third_circle(
506
710
  Returns:
507
711
  - str: The SVG element as a string.
508
712
  """
509
- if chart_type in {"Synastry", "Transit"}:
713
+ if chart_type in {"Synastry", "Transit", "DualReturnChart"}:
510
714
  # For Synastry and Transit charts, use a fixed radius adjustment of 160
511
715
  return f'<circle cx="{radius}" cy="{radius}" r="{radius - 160}" style="fill: {fill_color}; fill-opacity:.8; stroke: {stroke_color}; stroke-width: 1px" />'
512
716
 
@@ -515,10 +719,10 @@ def draw_third_circle(
515
719
 
516
720
 
517
721
  def draw_aspect_grid(
518
- stroke_color: str,
519
- available_planets: list,
722
+ stroke_color: str,
723
+ available_planets: list,
520
724
  aspects: list,
521
- x_start: int = 380,
725
+ x_start: int = 510,
522
726
  y_start: int = 468,
523
727
  ) -> str:
524
728
  """
@@ -535,18 +739,18 @@ def draw_aspect_grid(
535
739
  str: SVG string representing the aspect grid.
536
740
  """
537
741
  svg_output = ""
538
- style = f"stroke:{stroke_color}; stroke-width: 1px; fill:none"
742
+ style = f"stroke:{stroke_color}; stroke-width: 1px; stroke-width: 0.5px; fill:none"
539
743
  box_size = 14
540
744
 
541
745
  # Filter active planets
542
- 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"]]
543
747
 
544
748
  # Reverse the list of active planets for the first iteration
545
749
  reversed_planets = active_planets[::-1]
546
750
 
547
751
  for index, planet_a in enumerate(reversed_planets):
548
752
  # Draw the grid box for the planet
549
- svg_output += f'<rect x="{x_start}" y="{y_start}" width="{box_size}" height="{box_size}" style="{style}"/>'
753
+ svg_output += f'<rect kr:node="AspectsGridRect" x="{x_start}" y="{y_start}" width="{box_size}" height="{box_size}" style="{style}"/>'
550
754
  svg_output += f'<use transform="scale(0.4)" x="{(x_start + 2) * 2.5}" y="{(y_start + 1) * 2.5}" xlink:href="#{planet_a["name"]}" />'
551
755
 
552
756
  # Update the starting coordinates for the next box
@@ -560,7 +764,7 @@ def draw_aspect_grid(
560
764
  # Iterate over the remaining planets
561
765
  for planet_b in reversed_planets[index + 1:]:
562
766
  # Draw the grid box for the aspect
563
- svg_output += f'<rect x="{x_aspect}" y="{y_aspect}" width="{box_size}" height="{box_size}" style="{style}"/>'
767
+ svg_output += f'<rect kr:node="AspectsGridRect" x="{x_aspect}" y="{y_aspect}" width="{box_size}" height="{box_size}" style="{style}"/>'
564
768
  x_aspect += box_size
565
769
 
566
770
  # Check for aspects between the planets
@@ -586,6 +790,7 @@ def draw_houses_cusps_and_text_number(
586
790
  chart_type: ChartType,
587
791
  second_subject_houses_list: Union[list[KerykeionPointModel], None] = None,
588
792
  transit_house_cusp_color: Union[str, None] = None,
793
+ external_view: bool = False,
589
794
  ) -> str:
590
795
  """
591
796
  Draws the houses cusps and text numbers for a given chart type.
@@ -603,9 +808,10 @@ def draw_houses_cusps_and_text_number(
603
808
  - chart_type: Type of the chart (e.g., Transit, Synastry).
604
809
  - second_subject_houses_list: List of house for the second subject (optional).
605
810
  - transit_house_cusp_color: Color for transit house cusps (optional).
811
+ - external_view: Whether to use external view mode for positioning (optional).
606
812
 
607
813
  Returns:
608
- - A string containing the SVG path for the houses cusps and text numbers.
814
+ - A string containing SVG elements for house cusps and numbers.
609
815
  """
610
816
 
611
817
  path = ""
@@ -613,7 +819,7 @@ def draw_houses_cusps_and_text_number(
613
819
 
614
820
  for i in range(xr):
615
821
  # Determine offsets based on chart type
616
- 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)
617
823
 
618
824
  # Calculate the offset for the current house cusp
619
825
  offset = (int(first_subject_houses_list[int(xr / 2)].abs_pos) / -1) + int(first_subject_houses_list[i].abs_pos)
@@ -635,7 +841,7 @@ def draw_houses_cusps_and_text_number(
635
841
  i, standard_house_cusp_color
636
842
  )
637
843
 
638
- if chart_type in ["Transit", "Synastry"]:
844
+ if chart_type in ["Transit", "Synastry", "DualReturnChart"]:
639
845
  if second_subject_houses_list is None or transit_house_cusp_color is None:
640
846
  raise KerykeionException("second_subject_houses_list_ut or transit_house_cusp_color is None")
641
847
 
@@ -659,30 +865,34 @@ def draw_houses_cusps_and_text_number(
659
865
 
660
866
  # Add the house number text for the second subject
661
867
  fill_opacity = "0" if chart_type == "Transit" else ".4"
662
- path += f'<g kr:node="HouseNumber">'
868
+ path += '<g kr:node="HouseNumber">'
663
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>'
664
- path += f"</g>"
870
+ path += "</g>"
665
871
 
666
872
  # Add the house cusp line for the second subject
667
873
  stroke_opacity = "0" if chart_type == "Transit" else ".3"
668
- 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}">'
669
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};'/>"
670
- path += f"</g>"
876
+ path += "</g>"
671
877
 
672
- # Adjust dropin based on chart type
673
- 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)
674
884
  xtext = sliceToX(0, (r - dropin), text_offset) + dropin
675
885
  ytext = sliceToY(0, (r - dropin), text_offset) + dropin
676
886
 
677
887
  # Add the house cusp line for the first subject
678
- 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}">'
679
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;"/>'
680
- path += f"</g>"
890
+ path += "</g>"
681
891
 
682
892
  # Add the house number text for the first subject
683
- path += f'<g kr:node="HouseNumber">'
893
+ path += '<g kr:node="HouseNumber">'
684
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>'
685
- path += f"</g>"
895
+ path += "</g>"
686
896
 
687
897
  return path
688
898
 
@@ -691,7 +901,13 @@ def draw_transit_aspect_list(
691
901
  grid_title: str,
692
902
  aspects_list: Union[list[AspectModel], list[dict]],
693
903
  celestial_point_language: Union[KerykeionLanguageCelestialPointModel, dict],
694
- 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,
695
911
  ) -> str:
696
912
  """
697
913
  Generates the SVG output for the aspect transit grid.
@@ -699,69 +915,106 @@ def draw_transit_aspect_list(
699
915
  Parameters:
700
916
  - grid_title: Title of the grid.
701
917
  - aspects_list: List of aspects.
702
- - planets_labels: Dictionary containing the planet labels.
918
+ - celestial_point_language: Dictionary containing the celestial point language data.
703
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).
704
926
 
705
927
  Returns:
706
928
  - A string containing the SVG path data for the aspect transit grid.
707
929
  """
708
-
930
+
709
931
  if isinstance(celestial_point_language, dict):
710
932
  celestial_point_language = KerykeionLanguageCelestialPointModel(**celestial_point_language)
711
933
 
712
- if isinstance(aspects_settings, dict):
713
- aspects_settings = KerykeionSettingsAspectModel(**aspects_settings)
714
-
715
934
  # If not instance of AspectModel, convert to AspectModel
716
- if isinstance(aspects_list[0], dict):
717
- 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
718
952
 
719
- line = 0
720
- nl = 0
721
953
  inner_path = ""
722
- scale = 1
723
- for i, aspect in enumerate(aspects_list):
724
- # Adjust the vertical position for every 12 aspects
725
- if i == 12:
726
- nl = 100
727
- line = 0
728
-
729
- elif i == 24:
730
- nl = 200
731
- line = 0
732
-
733
- elif i == 36:
734
- nl = 300
735
- line = 0
736
-
737
- elif i == 48:
738
- nl = 400
739
- # When there are more than 60 aspects, the text is moved up
740
- if len(aspects_list) > 60:
741
- line = -1 * (len(aspects_list) - 60) * 14
742
- else:
743
- line = 0
744
-
745
- inner_path += f'<g transform="translate({nl},{line})">'
746
-
747
- # first planet symbol
748
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspects_list[i]["p1"]]["name"]}" />'
749
-
750
- # aspect symbol
751
- inner_path += f'<use x="15" y="0" xlink:href="#orb{aspects_settings[aspects_list[i]["aid"]]["degree"]}" />'
752
-
753
- # second planet symbol
754
- inner_path += f'<g transform="translate(30,0)">'
755
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspects_list[i]["p2"]]["name"]}" />'
756
- inner_path += f"</g>"
757
-
758
- # difference in degrees
759
- 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>'
760
- # line
761
- inner_path += f"</g>"
762
- line = line + 14
763
-
764
- out = f'<g style="transform: translate(47%, 59%) scale({scale})">'
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})">'
765
1018
  out += f'<text y="-15" x="0" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 14px;">{grid_title}:</text>'
766
1019
  out += inner_path
767
1020
  out += "</g>"
@@ -769,105 +1022,59 @@ def draw_transit_aspect_list(
769
1022
  return out
770
1023
 
771
1024
 
772
- def draw_moon_phase(
773
- degrees_between_sun_and_moon: float,
774
- latitude: float
775
- ) -> str:
1025
+ def calculate_moon_phase_chart_params(
1026
+ degrees_between_sun_and_moon: float
1027
+ ) -> dict:
776
1028
  """
777
- Draws the moon phase based on the degrees between the sun and the moon.
1029
+ Calculate normalized parameters used by the moon phase icon.
778
1030
 
779
1031
  Parameters:
780
- - degrees_between_sun_and_moon (float): The degrees between the sun and the moon.
781
- - latitude (float): The latitude for rotation calculation.
782
- - lunar_phase_outline_color (str): The color for the lunar phase outline.
783
- - dark_color (str): The color for the dark part of the moon.
784
- - light_color (str): The color for the light part of the moon.
1032
+ - degrees_between_sun_and_moon (float): The elongation between the sun and moon.
785
1033
 
786
1034
  Returns:
787
- - str: The SVG element as a string.
1035
+ - dict: Normalized phase data (angle, illuminated fraction, shadow ellipse radius).
788
1036
  """
789
- deg = degrees_between_sun_and_moon
790
-
791
- # Initialize variables for lunar phase properties
792
- circle_center_x = None
793
- circle_radius = None
794
-
795
- # Determine lunar phase properties based on the degree
796
- if deg < 90.0:
797
- max_radius = deg
798
- if deg > 80.0:
799
- max_radius = max_radius * max_radius
800
- circle_center_x = 20.0 + (deg / 90.0) * (max_radius + 10.0)
801
- circle_radius = 10.0 + (deg / 90.0) * max_radius
802
-
803
- elif deg < 180.0:
804
- max_radius = 180.0 - deg
805
- if deg < 100.0:
806
- max_radius = max_radius * max_radius
807
- circle_center_x = 20.0 + ((deg - 90.0) / 90.0 * (max_radius + 10.0)) - (max_radius + 10.0)
808
- circle_radius = 10.0 + max_radius - ((deg - 90.0) / 90.0 * max_radius)
809
-
810
- elif deg < 270.0:
811
- max_radius = deg - 180.0
812
- if deg > 260.0:
813
- max_radius = max_radius * max_radius
814
- circle_center_x = 20.0 + ((deg - 180.0) / 90.0 * (max_radius + 10.0))
815
- circle_radius = 10.0 + ((deg - 180.0) / 90.0 * max_radius)
816
-
817
- elif deg < 361.0:
818
- max_radius = 360.0 - deg
819
- if deg < 280.0:
820
- max_radius = max_radius * max_radius
821
- circle_center_x = 20.0 + ((deg - 270.0) / 90.0 * (max_radius + 10.0)) - (max_radius + 10.0)
822
- circle_radius = 10.0 + max_radius - ((deg - 270.0) / 90.0 * max_radius)
823
-
824
- else:
825
- raise KerykeionException(f"Invalid degree value: {deg}")
1037
+ if not math.isfinite(degrees_between_sun_and_moon):
1038
+ raise KerykeionException(
1039
+ f"Invalid degree value: {degrees_between_sun_and_moon}"
1040
+ )
826
1041
 
1042
+ phase_angle = degrees_between_sun_and_moon % 360.0
1043
+ radians = math.radians(phase_angle)
1044
+ cosine = math.cos(radians)
1045
+ illuminated_fraction = (1.0 - cosine) / 2.0
827
1046
 
828
- # Calculate rotation based on latitude
829
- lunar_phase_rotate = -90.0 - latitude
1047
+ # Guard against floating point spillover outside [0, 1].
1048
+ illuminated_fraction = max(0.0, min(1.0, illuminated_fraction))
830
1049
 
831
- # Return the SVG element as a string
832
- return (
833
- f'<g transform="rotate({lunar_phase_rotate} 20 10)">'
834
- f' <defs>'
835
- f' <clipPath id="moonPhaseCutOffCircle">'
836
- f' <circle cx="20" cy="10" r="10" />'
837
- f' </clipPath>'
838
- f' </defs>'
839
- f' <circle cx="20" cy="10" r="10" style="fill: var(--kerykeion-chart-color-lunar-phase-0)" />'
840
- f' <circle cx="{circle_center_x}" cy="10" r="{circle_radius}" style="fill: var(--kerykeion-chart-color-lunar-phase-1)" clip-path="url(#moonPhaseCutOffCircle)" />'
841
- 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" />'
842
- f'</g>'
843
- )
1050
+ return {
1051
+ "phase_angle": phase_angle,
1052
+ "illuminated_fraction": illuminated_fraction,
1053
+ "shadow_ellipse_rx": 10.0 * cosine,
1054
+ }
844
1055
 
845
1056
 
846
- def draw_house_grid(
1057
+ def draw_main_house_grid(
847
1058
  main_subject_houses_list: list[KerykeionPointModel],
848
- chart_type: ChartType,
849
- secondary_subject_houses_list: Union[list[KerykeionPointModel], None] = None,
850
- text_color: str = "#000000",
851
1059
  house_cusp_generale_name_label: str = "Cusp",
1060
+ text_color: str = "#000000",
1061
+ x_position: int = 750,
1062
+ y_position: int = 30,
852
1063
  ) -> str:
853
1064
  """
854
- Generate SVG code for a grid of astrological houses.
1065
+ Generate SVG code for a grid of astrological houses for the main subject.
855
1066
 
856
1067
  Parameters:
857
- - main_houses (list[KerykeionPointModel]): List of houses for the main subject.
858
- - chart_type (ChartType): Type of the chart (e.g., Synastry, Transit).
859
- - secondary_houses (list[KerykeionPointModel], optional): List of houses for the secondary subject.
860
- - text_color (str): Color of the text.
861
- - cusp_label (str): Label for the house cusp.
1068
+ - main_subject_houses_list (list[KerykeionPointModel]): List of houses for the main subject.
1069
+ - house_cusp_generale_name_label (str): Label for the house cusp. Defaults to "Cusp".
1070
+ - text_color (str): Color of the text. Defaults to "#000000".
1071
+ - x_position (int): X position for the grid. Defaults to 720.
1072
+ - y_position (int): Y position for the grid. Defaults to 30.
862
1073
 
863
1074
  Returns:
864
1075
  - str: The SVG code for the grid of houses.
865
1076
  """
866
-
867
- if chart_type in ["Synastry", "Transit"] and secondary_subject_houses_list is None:
868
- raise KerykeionException("secondary_houses is None")
869
-
870
- svg_output = '<g transform="translate(610,-20)">'
1077
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
871
1078
 
872
1079
  line_increment = 10
873
1080
  for i, house in enumerate(main_subject_houses_list):
@@ -882,75 +1089,108 @@ def draw_house_grid(
882
1089
  line_increment += 14
883
1090
 
884
1091
  svg_output += "</g>"
1092
+ return svg_output
885
1093
 
886
- if chart_type == "Synastry":
887
- svg_output += '<!-- Synastry Houses -->'
888
- svg_output += '<g transform="translate(850, -20)">'
889
- line_increment = 10
890
-
891
- for i, house in enumerate(secondary_subject_houses_list): # type: ignore
892
- cusp_number = f"&#160;&#160;{i + 1}" if i < 9 else str(i + 1)
893
- svg_output += (
894
- f'<g transform="translate(0,{line_increment})">'
895
- f'<text text-anchor="end" x="40" style="fill:{text_color}; font-size: 10px;">{house_cusp_generale_name_label} {cusp_number}:</text>'
896
- f'<g transform="translate(40,-8)"><use transform="scale(0.3)" xlink:href="#{house["sign"]}" /></g>'
897
- f'<text x="53" style="fill:{text_color}; font-size: 10px;"> {convert_decimal_to_degree_string(house["position"])}</text>'
898
- f'</g>'
899
- )
900
- line_increment += 14
901
1094
 
902
- svg_output += "</g>"
1095
+ def draw_secondary_house_grid(
1096
+ secondary_subject_houses_list: list[KerykeionPointModel],
1097
+ house_cusp_generale_name_label: str = "Cusp",
1098
+ text_color: str = "#000000",
1099
+ x_position: int = 1015,
1100
+ y_position: int = 30,
1101
+ ) -> str:
1102
+ """
1103
+ Generate SVG code for a grid of astrological houses for the secondary subject.
1104
+
1105
+ Parameters:
1106
+ - secondary_subject_houses_list (list[KerykeionPointModel]): List of houses for the secondary subject.
1107
+ - house_cusp_generale_name_label (str): Label for the house cusp. Defaults to "Cusp".
1108
+ - text_color (str): Color of the text. Defaults to "#000000".
1109
+ - x_position (int): X position for the grid. Defaults to 970.
1110
+ - y_position (int): Y position for the grid. Defaults to 30.
1111
+
1112
+ Returns:
1113
+ - str: The SVG code for the grid of houses.
1114
+ """
1115
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1116
+
1117
+ line_increment = 10
1118
+ for i, house in enumerate(secondary_subject_houses_list):
1119
+ cusp_number = f"&#160;&#160;{i + 1}" if i < 9 else str(i + 1)
1120
+ svg_output += (
1121
+ f'<g transform="translate(0,{line_increment})">'
1122
+ f'<text text-anchor="end" x="40" style="fill:{text_color}; font-size: 10px;">{house_cusp_generale_name_label} {cusp_number}:</text>'
1123
+ f'<g transform="translate(40,-8)"><use transform="scale(0.3)" xlink:href="#{house["sign"]}" /></g>'
1124
+ f'<text x="53" style="fill:{text_color}; font-size: 10px;"> {convert_decimal_to_degree_string(house["position"])}</text>'
1125
+ f'</g>'
1126
+ )
1127
+ line_increment += 14
903
1128
 
1129
+ svg_output += "</g>"
904
1130
  return svg_output
905
1131
 
906
1132
 
907
- def draw_planet_grid(
1133
+ def draw_main_planet_grid(
908
1134
  planets_and_houses_grid_title: str,
909
1135
  subject_name: str,
910
1136
  available_kerykeion_celestial_points: list[KerykeionPointModel],
911
1137
  chart_type: ChartType,
912
1138
  celestial_point_language: KerykeionLanguageCelestialPointModel,
913
- second_subject_name: Union[str, None] = None,
914
- second_subject_available_kerykeion_celestial_points: Union[list[KerykeionPointModel], None] = None,
915
1139
  text_color: str = "#000000",
1140
+ x_position: int = 645,
1141
+ y_position: int = 0,
916
1142
  ) -> str:
917
1143
  """
918
- Draws the planet grid for the given celestial points and chart type.
1144
+ Draw the planet grid (main subject) and optional title.
1145
+
1146
+ The entire output is wrapped in a single SVG group `<g>` so the
1147
+ whole block can be repositioned by changing the group transform.
919
1148
 
920
1149
  Args:
921
- planets_and_houses_grid_title (str): Title of the grid.
922
- subject_name (str): Name of the subject.
923
- available_kerykeion_celestial_points (list[KerykeionPointModel]): List of celestial points for the subject.
924
- chart_type (ChartType): Type of the chart.
925
- celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points.
926
- second_subject_name (str, optional): Name of the second subject. Defaults to None.
927
- second_subject_available_kerykeion_celestial_points (list[KerykeionPointModel], optional): List of celestial points for the second subject. Defaults to None.
928
- text_color (str, optional): Color of the text. Defaults to "#000000".
1150
+ planets_and_houses_grid_title: Title prefix to show for eligible chart types.
1151
+ subject_name: Subject name to append to the title.
1152
+ available_kerykeion_celestial_points: Celestial points to render in the grid.
1153
+ chart_type: Chart type identifier (Literal string).
1154
+ celestial_point_language: Language model for celestial point decoding.
1155
+ text_color: Text color for labels (default: "#000000").
1156
+ x_position: X translation applied to the outer `<g>` (default: 620).
1157
+ y_position: Y translation applied to the outer `<g>` (default: 0).
929
1158
 
930
1159
  Returns:
931
- str: The SVG output for the planet grid.
1160
+ SVG string for the main planet grid wrapped in a `<g>`.
932
1161
  """
933
- line_height = 10
934
- offset = 0
935
- offset_between_lines = 14
1162
+ # Layout constants (kept identical to previous behavior)
1163
+ BASE_Y = 30
1164
+ HEADER_Y = 15 # Title baseline inside the wrapper
1165
+ LINE_START = 10
1166
+ LINE_STEP = 14
936
1167
 
937
- svg_output = (
938
- f'<g transform="translate(510,-20)">'
939
- f'<g transform="translate(140, -15)">'
940
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}:</text>'
941
- f'</g>'
942
- )
1168
+ # Wrap everything inside a single group so position can be changed once
1169
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1170
+
1171
+ # Add title only for specific chart types
1172
+ if chart_type in ("Synastry", "Transit", "DualReturnChart"):
1173
+ svg_output += (
1174
+ f'<g transform="translate(0, {HEADER_Y})">'
1175
+ f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}</text>'
1176
+ f'</g>'
1177
+ )
943
1178
 
944
1179
  end_of_line = "</g>"
945
1180
 
1181
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1182
+
946
1183
  for i, planet in enumerate(available_kerykeion_celestial_points):
947
- if i == 27:
948
- line_height = 10
949
- offset = -120
1184
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1185
+ line_height = LINE_START + (row_index * LINE_STEP)
1186
+
1187
+ decoded_name = get_decoded_kerykeion_celestial_point_name(
1188
+ planet["name"],
1189
+ celestial_point_language,
1190
+ )
950
1191
 
951
- decoded_name = get_decoded_kerykeion_celestial_point_name(planet["name"], celestial_point_language)
952
1192
  svg_output += (
953
- f'<g transform="translate({offset},{line_height})">'
1193
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
954
1194
  f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{decoded_name}</text>'
955
1195
  f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{planet["name"]}" /></g>'
956
1196
  f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(planet["position"])}</text>'
@@ -961,55 +1201,101 @@ def draw_planet_grid(
961
1201
  svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
962
1202
 
963
1203
  svg_output += end_of_line
964
- line_height += offset_between_lines
965
1204
 
966
- if chart_type in ["Transit", "Synastry"]:
967
- if second_subject_available_kerykeion_celestial_points is None:
968
- raise KerykeionException("second_subject_available_kerykeion_celestial_points is None")
1205
+ # Close the wrapper group
1206
+ svg_output += "</g>"
969
1207
 
970
- if chart_type == "Transit":
971
- svg_output += (
972
- f'<g transform="translate(320, -15)">'
973
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 14px;">{second_subject_name}:</text>'
974
- )
975
- else:
976
- svg_output += (
977
- f'<g transform="translate(380, -15)">'
978
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {second_subject_name}:</text>'
979
- )
1208
+ return svg_output
980
1209
 
981
- svg_output += end_of_line
982
1210
 
983
- second_line_height = 10
984
- second_offset = 250
985
-
986
- for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
987
- if i == 27:
988
- second_line_height = 10
989
- second_offset = -120
990
-
991
- second_decoded_name = get_decoded_kerykeion_celestial_point_name(t_planet["name"], celestial_point_language)
992
- svg_output += (
993
- f'<g transform="translate({second_offset},{second_line_height})">'
994
- f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{second_decoded_name}</text>'
995
- f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{t_planet["name"]}" /></g>'
996
- f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(t_planet["position"])}</text>'
997
- f'<g transform="translate(60,-8)"><use transform="scale(0.3)" xlink:href="#{t_planet["sign"]}" /></g>'
998
- )
1211
+ def draw_secondary_planet_grid(
1212
+ planets_and_houses_grid_title: str,
1213
+ second_subject_name: str,
1214
+ second_subject_available_kerykeion_celestial_points: list[KerykeionPointModel],
1215
+ chart_type: ChartType,
1216
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1217
+ text_color: str = "#000000",
1218
+ x_position: int = 910,
1219
+ y_position: int = 0,
1220
+ ) -> str:
1221
+ """
1222
+ Draw the planet grid for the secondary subject and its title.
1223
+
1224
+ The entire output is wrapped in a single SVG group `<g>` so the
1225
+ whole block can be repositioned by changing the group transform.
1226
+
1227
+ Args:
1228
+ planets_and_houses_grid_title: Title prefix (used except for Transit charts).
1229
+ second_subject_name: Name of the secondary subject.
1230
+ second_subject_available_kerykeion_celestial_points: Celestial points to render for the secondary subject.
1231
+ chart_type: Chart type identifier (Literal string).
1232
+ celestial_point_language: Language model for celestial point decoding.
1233
+ text_color: Text color for labels (default: "#000000").
1234
+ x_position: X translation applied to the outer `<g>` (default: 870).
1235
+ y_position: Y translation applied to the outer `<g>` (default: 0).
1236
+
1237
+ Returns:
1238
+ SVG string for the secondary planet grid wrapped in a `<g>`.
1239
+ """
1240
+ # Layout constants
1241
+ BASE_Y = 30
1242
+ HEADER_Y = 15
1243
+ LINE_START = 10
1244
+ LINE_STEP = 14
1245
+
1246
+ # Open wrapper group
1247
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1248
+
1249
+ # Title content and its relative x offset
1250
+ header_text = (
1251
+ second_subject_name if chart_type == "Transit"
1252
+ else f"{planets_and_houses_grid_title} {second_subject_name}"
1253
+ )
1254
+ header_x_offset = -50 if chart_type == "Transit" else 0
1255
+
1256
+ svg_output += (
1257
+ f'<g transform="translate({header_x_offset}, {HEADER_Y})">'
1258
+ f'<text style="fill:{text_color}; font-size: 14px;">{header_text}</text>'
1259
+ f'</g>'
1260
+ )
1261
+
1262
+ # Grid rows
1263
+ line_height = LINE_START
1264
+ end_of_line = "</g>"
1265
+
1266
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1267
+
1268
+ for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
1269
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1270
+ line_height = LINE_START + (row_index * LINE_STEP)
1271
+
1272
+ second_decoded_name = get_decoded_kerykeion_celestial_point_name(
1273
+ t_planet["name"],
1274
+ celestial_point_language,
1275
+ )
1276
+ svg_output += (
1277
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
1278
+ f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{second_decoded_name}</text>'
1279
+ f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{t_planet["name"]}" /></g>'
1280
+ f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(t_planet["position"])}</text>'
1281
+ f'<g transform="translate(60,-8)"><use transform="scale(0.3)" xlink:href="#{t_planet["sign"]}" /></g>'
1282
+ )
1283
+
1284
+ if t_planet["retrograde"]:
1285
+ svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1286
+
1287
+ svg_output += end_of_line
999
1288
 
1000
- if t_planet["retrograde"]:
1001
- svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1002
1289
 
1003
- svg_output += end_of_line
1004
- second_line_height += offset_between_lines
1290
+ # Close wrapper group
1291
+ svg_output += "</g>"
1005
1292
 
1006
- svg_output += end_of_line
1007
1293
  return svg_output
1008
1294
 
1009
1295
 
1010
1296
  def draw_transit_aspect_grid(
1011
- stroke_color: str,
1012
- available_planets: list,
1297
+ stroke_color: str,
1298
+ available_planets: list,
1013
1299
  aspects: list,
1014
1300
  x_indent: int = 50,
1015
1301
  y_indent: int = 250,
@@ -1030,12 +1316,12 @@ def draw_transit_aspect_grid(
1030
1316
  str: SVG string representing the aspect grid.
1031
1317
  """
1032
1318
  svg_output = ""
1033
- style = f"stroke:{stroke_color}; stroke-width: 1px; fill:none"
1319
+ style = f"stroke:{stroke_color}; stroke-width: 1px; stroke-width: 0.5px; fill:none"
1034
1320
  x_start = x_indent
1035
1321
  y_start = y_indent
1036
1322
 
1037
1323
  # Filter active planets
1038
- active_planets = [planet for planet in available_planets if planet.is_active]
1324
+ active_planets = [planet for planet in available_planets if planet["is_active"]]
1039
1325
 
1040
1326
  # Reverse the list of active planets for the first iteration
1041
1327
  reversed_planets = active_planets[::-1]
@@ -1080,4 +1366,475 @@ def draw_transit_aspect_grid(
1080
1366
  if (aspect["p1"] == planet_a["id"] and aspect["p2"] == planet_b["id"]):
1081
1367
  svg_output += f'<use x="{x_aspect - box_size + 1}" y="{y_aspect + 1}" xlink:href="#orb{aspect["aspect_degrees"]}" />'
1082
1368
 
1083
- return svg_output
1369
+ return svg_output
1370
+
1371
+
1372
+ def format_location_string(location: str, max_length: int = 35) -> str:
1373
+ """
1374
+ Format a location string to ensure it fits within a specified maximum length.
1375
+
1376
+ If the location is longer than max_length, it attempts to shorten by using only
1377
+ the first and last parts separated by commas. If still too long, it truncates
1378
+ and adds ellipsis.
1379
+
1380
+ Args:
1381
+ location: The original location string
1382
+ max_length: Maximum allowed length for the output string (default: 35)
1383
+
1384
+ Returns:
1385
+ Formatted location string that fits within max_length
1386
+ """
1387
+ if len(location) > max_length:
1388
+ split_location = location.split(",")
1389
+ if len(split_location) > 1:
1390
+ shortened = split_location[0] + ", " + split_location[-1]
1391
+ if len(shortened) > max_length:
1392
+ return shortened[:max_length] + "..."
1393
+ return shortened
1394
+ else:
1395
+ return location[:max_length] + "..."
1396
+ return location
1397
+
1398
+
1399
+ def format_datetime_with_timezone(iso_datetime_string: str) -> str:
1400
+ """
1401
+ Format an ISO datetime string with a custom format that includes properly formatted timezone.
1402
+
1403
+ Args:
1404
+ iso_datetime_string: ISO formatted datetime string
1405
+
1406
+ Returns:
1407
+ Formatted datetime string with properly formatted timezone offset (HH:MM)
1408
+ """
1409
+ dt = datetime.datetime.fromisoformat(iso_datetime_string)
1410
+ custom_format = dt.strftime('%Y-%m-%d %H:%M [%z]')
1411
+ custom_format = custom_format[:-3] + ':' + custom_format[-3:]
1412
+
1413
+ return custom_format
1414
+
1415
+
1416
+ def calculate_element_points(
1417
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1418
+ celestial_points_names: Sequence[str],
1419
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1420
+ *,
1421
+ method: ElementQualityDistributionMethod = "weighted",
1422
+ custom_weights: Optional[Mapping[str, float]] = None,
1423
+ ) -> dict[str, float]:
1424
+ """
1425
+ Calculate elemental totals for a subject using the selected strategy.
1426
+
1427
+ Args:
1428
+ planets_settings: Planet configuration list (kept for API compatibility).
1429
+ celestial_points_names: Celestial point names to include.
1430
+ subject: Astrological subject with planetary data.
1431
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1432
+ custom_weights: Optional overrides for point weights keyed by name.
1433
+
1434
+ Returns:
1435
+ Dictionary mapping each element to its accumulated total.
1436
+ """
1437
+ normalized_names = [name.lower() for name in celestial_points_names]
1438
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1439
+
1440
+ return _calculate_distribution_for_subject(
1441
+ subject,
1442
+ normalized_names,
1443
+ _SIGN_TO_ELEMENT,
1444
+ _ELEMENT_KEYS,
1445
+ weight_lookup,
1446
+ fallback_weight,
1447
+ )
1448
+
1449
+
1450
+ def calculate_synastry_element_points(
1451
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1452
+ celestial_points_names: Sequence[str],
1453
+ subject1: AstrologicalSubjectModel,
1454
+ subject2: AstrologicalSubjectModel,
1455
+ *,
1456
+ method: ElementQualityDistributionMethod = "weighted",
1457
+ custom_weights: Optional[Mapping[str, float]] = None,
1458
+ ) -> dict[str, float]:
1459
+ """
1460
+ Calculate combined element percentages for a synastry chart.
1461
+
1462
+ Args:
1463
+ planets_settings: Planet configuration list (unused but preserved).
1464
+ celestial_points_names: Celestial point names to process.
1465
+ subject1: First astrological subject.
1466
+ subject2: Second astrological subject.
1467
+ method: Calculation strategy (pure_count or weighted).
1468
+ custom_weights: Optional overrides for point weights.
1469
+
1470
+ Returns:
1471
+ Dictionary with element percentages summing to 100.
1472
+ """
1473
+ normalized_names = [name.lower() for name in celestial_points_names]
1474
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1475
+
1476
+ subject1_totals = _calculate_distribution_for_subject(
1477
+ subject1,
1478
+ normalized_names,
1479
+ _SIGN_TO_ELEMENT,
1480
+ _ELEMENT_KEYS,
1481
+ weight_lookup,
1482
+ fallback_weight,
1483
+ )
1484
+ subject2_totals = _calculate_distribution_for_subject(
1485
+ subject2,
1486
+ normalized_names,
1487
+ _SIGN_TO_ELEMENT,
1488
+ _ELEMENT_KEYS,
1489
+ weight_lookup,
1490
+ fallback_weight,
1491
+ )
1492
+
1493
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _ELEMENT_KEYS}
1494
+ total_points = sum(combined_totals.values())
1495
+
1496
+ if total_points == 0:
1497
+ return {key: 0.0 for key in _ELEMENT_KEYS}
1498
+
1499
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _ELEMENT_KEYS}
1500
+
1501
+
1502
+ def draw_house_comparison_grid(
1503
+ house_comparison: "HouseComparisonModel",
1504
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1505
+ active_points: list[AstrologicalPoint],
1506
+ *,
1507
+ points_owner_subject_number: Literal[1, 2] = 1,
1508
+ text_color: str = "var(--kerykeion-color-neutral-content)",
1509
+ house_position_comparison_label: str = "House Position Comparison",
1510
+ return_point_label: str = "Return Point",
1511
+ return_label: str = "DualReturnChart",
1512
+ radix_label: str = "Radix",
1513
+ x_position: int = 1100,
1514
+ y_position: int = 0,
1515
+ ) -> str:
1516
+ """
1517
+ Generate SVG code for displaying a comparison of points across houses between two charts.
1518
+
1519
+ Parameters:
1520
+ - house_comparison ("HouseComparisonModel"): Model containing house comparison data,
1521
+ including first_subject_name, second_subject_name, and points in houses.
1522
+ - celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points
1523
+ - active_celestial_points (list[KerykeionPointModel]): List of active celestial points to display
1524
+ - text_color (str): Color for the text elements
1525
+
1526
+ Returns:
1527
+ - str: SVG code for the house comparison grid.
1528
+ """
1529
+ if points_owner_subject_number == 1:
1530
+ comparison_data = house_comparison.first_points_in_second_houses
1531
+ else:
1532
+ comparison_data = house_comparison.second_points_in_first_houses
1533
+
1534
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1535
+
1536
+ # Add title
1537
+ svg_output += f'<text text-anchor="start" x="0" y="-15" style="fill:{text_color}; font-size: 14px;">{house_position_comparison_label}</text>'
1538
+
1539
+ # Add column headers
1540
+ line_increment = 10
1541
+ svg_output += (
1542
+ f'<g transform="translate(0,{line_increment})">'
1543
+ f'<text text-anchor="start" x="0" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{return_point_label}</text>'
1544
+ f'<text text-anchor="start" x="77" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{return_label}</text>'
1545
+ f'<text text-anchor="start" x="132" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{radix_label}</text>'
1546
+ f'</g>'
1547
+ )
1548
+ line_increment += 15
1549
+
1550
+ # Create a dictionary to store all points by name for combined display
1551
+ all_points_by_name = {}
1552
+
1553
+ for point in comparison_data:
1554
+ # Only process points that are active
1555
+ if point.point_name in active_points and point.point_name not in all_points_by_name:
1556
+ all_points_by_name[point.point_name] = {
1557
+ "name": point.point_name,
1558
+ "secondary_house": point.projected_house_number,
1559
+ "native_house": point.point_owner_house_number
1560
+ }
1561
+
1562
+ # Display all points organized by name
1563
+ for name, point_data in all_points_by_name.items():
1564
+ native_house = point_data.get("native_house", "-")
1565
+ secondary_house = point_data.get("secondary_house", "-")
1566
+
1567
+ svg_output += (
1568
+ f'<g transform="translate(0,{line_increment})">'
1569
+ f'<g transform="translate(0,-9)"><use transform="scale(0.4)" xlink:href="#{name}" /></g>'
1570
+ 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>'
1571
+ f'<text text-anchor="start" x="90" style="fill:{text_color}; font-size: 10px;">{native_house}</text>'
1572
+ f'<text text-anchor="start" x="140" style="fill:{text_color}; font-size: 10px;">{secondary_house}</text>'
1573
+ f'</g>'
1574
+ )
1575
+ line_increment += 12
1576
+
1577
+ svg_output += "</g>"
1578
+
1579
+ return svg_output
1580
+
1581
+
1582
+ def draw_single_house_comparison_grid(
1583
+ house_comparison: "HouseComparisonModel",
1584
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1585
+ active_points: list[AstrologicalPoint],
1586
+ *,
1587
+ points_owner_subject_number: Literal[1, 2] = 1,
1588
+ text_color: str = "var(--kerykeion-color-neutral-content)",
1589
+ house_position_comparison_label: str = "House Position Comparison",
1590
+ return_point_label: str = "Return Point",
1591
+ natal_house_label: str = "Natal House",
1592
+ x_position: int = 1030,
1593
+ y_position: int = 0,
1594
+ ) -> str:
1595
+ """
1596
+ Generate SVG code for displaying celestial points and their house positions.
1597
+
1598
+ Parameters:
1599
+ - house_comparison ("HouseComparisonModel"): Model containing house comparison data,
1600
+ including first_subject_name, second_subject_name, and points in houses.
1601
+ - celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points
1602
+ - active_points (list[AstrologicalPoint]): List of active celestial points to display
1603
+ - points_owner_subject_number (Literal[1, 2]): Which subject's points to display (1 for first, 2 for second)
1604
+ - text_color (str): Color for the text elements
1605
+ - house_position_comparison_label (str): Label for the house position comparison grid
1606
+ - return_point_label (str): Label for the return point column
1607
+ - house_position_label (str): Label for the house position column
1608
+ - x_position (int): X position for the grid
1609
+ - y_position (int): Y position for the grid
1610
+
1611
+ Returns:
1612
+ - str: SVG code for the house position grid.
1613
+ """
1614
+ if points_owner_subject_number == 1:
1615
+ comparison_data = house_comparison.first_points_in_second_houses
1616
+ else:
1617
+ comparison_data = house_comparison.second_points_in_first_houses
1618
+
1619
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1620
+
1621
+ # Add title
1622
+ svg_output += f'<text text-anchor="start" x="0" y="-15" style="fill:{text_color}; font-size: 14px;">{house_position_comparison_label}</text>'
1623
+
1624
+ # Add column headers
1625
+ line_increment = 10
1626
+ svg_output += (
1627
+ f'<g transform="translate(0,{line_increment})">'
1628
+ f'<text text-anchor="start" x="0" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{return_point_label}</text>'
1629
+ f'<text text-anchor="start" x="77" style="fill:{text_color}; font-weight: bold; font-size: 10px;">{natal_house_label}</text>'
1630
+ f'</g>'
1631
+ )
1632
+ line_increment += 15
1633
+
1634
+ # Create a dictionary to store all points by name for combined display
1635
+ all_points_by_name = {}
1636
+
1637
+ for point in comparison_data:
1638
+ # Only process points that are active
1639
+ if point.point_name in active_points and point.point_name not in all_points_by_name:
1640
+ all_points_by_name[point.point_name] = {
1641
+ "name": point.point_name,
1642
+ "house": point.projected_house_number
1643
+ }
1644
+
1645
+ # Display all points organized by name
1646
+ for name, point_data in all_points_by_name.items():
1647
+ house = point_data.get("house", "-")
1648
+
1649
+ svg_output += (
1650
+ f'<g transform="translate(0,{line_increment})">'
1651
+ f'<g transform="translate(0,-9)"><use transform="scale(0.4)" xlink:href="#{name}" /></g>'
1652
+ 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>'
1653
+ f'<text text-anchor="start" x="90" style="fill:{text_color}; font-size: 10px;">{house}</text>'
1654
+ f'</g>'
1655
+ )
1656
+ line_increment += 12
1657
+
1658
+ svg_output += "</g>"
1659
+
1660
+ return svg_output
1661
+
1662
+
1663
+ def makeLunarPhase(degrees_between_sun_and_moon: float, latitude: float) -> str:
1664
+ """
1665
+ Generate SVG representation of lunar phase.
1666
+
1667
+ Parameters:
1668
+ - degrees_between_sun_and_moon (float): Angle between sun and moon in degrees
1669
+ - latitude (float): Observer's latitude (no longer used, kept for backward compatibility)
1670
+
1671
+ Returns:
1672
+ - str: SVG representation of lunar phase
1673
+ """
1674
+ params = calculate_moon_phase_chart_params(degrees_between_sun_and_moon)
1675
+
1676
+ phase_angle = params["phase_angle"]
1677
+ illuminated_fraction = 1.0 - params["illuminated_fraction"]
1678
+ shadow_ellipse_rx = abs(params["shadow_ellipse_rx"])
1679
+
1680
+ radius = 10.0
1681
+ center_x = 20.0
1682
+ center_y = 10.0
1683
+
1684
+ bright_color = "var(--kerykeion-chart-color-lunar-phase-1)"
1685
+ shadow_color = "var(--kerykeion-chart-color-lunar-phase-0)"
1686
+
1687
+ is_waxing = phase_angle < 180.0
1688
+
1689
+ if illuminated_fraction <= 1e-6:
1690
+ base_fill = shadow_color
1691
+ overlay_path = ""
1692
+ overlay_fill = ""
1693
+ elif 1.0 - illuminated_fraction <= 1e-6:
1694
+ base_fill = bright_color
1695
+ overlay_path = ""
1696
+ overlay_fill = ""
1697
+ else:
1698
+ is_lit_major = illuminated_fraction >= 0.5
1699
+ if is_lit_major:
1700
+ base_fill = bright_color
1701
+ overlay_fill = shadow_color
1702
+ overlay_side = "left" if is_waxing else "right"
1703
+ else:
1704
+ base_fill = shadow_color
1705
+ overlay_fill = bright_color
1706
+ overlay_side = "right" if is_waxing else "left"
1707
+
1708
+ # The illuminated limb is the orthographic projection of the lunar terminator;
1709
+ # it appears as an ellipse with vertical radius equal to the lunar radius and
1710
+ # horizontal radius scaled by |cos(phase)|.
1711
+ def build_lune_path(side: str, ellipse_rx: float) -> str:
1712
+ ellipse_rx = max(0.0, min(radius, ellipse_rx))
1713
+ top_y = center_y - radius
1714
+ bottom_y = center_y + radius
1715
+ circle_sweep = 1 if side == "right" else 0
1716
+
1717
+ if ellipse_rx <= 1e-6:
1718
+ return (
1719
+ f"M {center_x:.4f} {top_y:.4f}"
1720
+ f" A {radius:.4f} {radius:.4f} 0 0 {circle_sweep} {center_x:.4f} {bottom_y:.4f}"
1721
+ f" L {center_x:.4f} {top_y:.4f}"
1722
+ " Z"
1723
+ )
1724
+
1725
+ return (
1726
+ f"M {center_x:.4f} {top_y:.4f}"
1727
+ f" A {radius:.4f} {radius:.4f} 0 0 {circle_sweep} {center_x:.4f} {bottom_y:.4f}"
1728
+ f" A {ellipse_rx:.4f} {radius:.4f} 0 0 {circle_sweep} {center_x:.4f} {top_y:.4f}"
1729
+ " Z"
1730
+ )
1731
+
1732
+ overlay_path = build_lune_path(overlay_side, shadow_ellipse_rx)
1733
+
1734
+ svg_lines = [
1735
+ '<g transform="rotate(0 20 10)">',
1736
+ ' <defs>',
1737
+ ' <clipPath id="moonPhaseCutOffCircle">',
1738
+ ' <circle cx="20" cy="10" r="10" />',
1739
+ ' </clipPath>',
1740
+ ' </defs>',
1741
+ f' <circle cx="20" cy="10" r="10" style="fill: {base_fill}" />',
1742
+ ]
1743
+
1744
+ if overlay_path:
1745
+ svg_lines.append(
1746
+ f' <path d="{overlay_path}" style="fill: {overlay_fill}" clip-path="url(#moonPhaseCutOffCircle)" />'
1747
+ )
1748
+
1749
+ svg_lines.append(
1750
+ ' <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" />'
1751
+ )
1752
+ svg_lines.append('</g>')
1753
+
1754
+ return "\n".join(svg_lines)
1755
+
1756
+
1757
+ def calculate_quality_points(
1758
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1759
+ celestial_points_names: Sequence[str],
1760
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1761
+ *,
1762
+ method: ElementQualityDistributionMethod = "weighted",
1763
+ custom_weights: Optional[Mapping[str, float]] = None,
1764
+ ) -> dict[str, float]:
1765
+ """
1766
+ Calculate modality totals for a subject using the selected strategy.
1767
+
1768
+ Args:
1769
+ planets_settings: Planet configuration list (kept for API compatibility).
1770
+ celestial_points_names: Celestial point names to include.
1771
+ subject: Astrological subject with planetary data.
1772
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1773
+ custom_weights: Optional overrides for point weights keyed by name.
1774
+
1775
+ Returns:
1776
+ Dictionary mapping each modality to its accumulated total.
1777
+ """
1778
+ normalized_names = [name.lower() for name in celestial_points_names]
1779
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1780
+
1781
+ return _calculate_distribution_for_subject(
1782
+ subject,
1783
+ normalized_names,
1784
+ _SIGN_TO_QUALITY,
1785
+ _QUALITY_KEYS,
1786
+ weight_lookup,
1787
+ fallback_weight,
1788
+ )
1789
+
1790
+
1791
+ def calculate_synastry_quality_points(
1792
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1793
+ celestial_points_names: Sequence[str],
1794
+ subject1: AstrologicalSubjectModel,
1795
+ subject2: AstrologicalSubjectModel,
1796
+ *,
1797
+ method: ElementQualityDistributionMethod = "weighted",
1798
+ custom_weights: Optional[Mapping[str, float]] = None,
1799
+ ) -> dict[str, float]:
1800
+ """
1801
+ Calculate combined modality percentages for a synastry chart.
1802
+
1803
+ Args:
1804
+ planets_settings: Planet configuration list (unused but preserved).
1805
+ celestial_points_names: Celestial point names to process.
1806
+ subject1: First astrological subject.
1807
+ subject2: Second astrological subject.
1808
+ method: Calculation strategy (pure_count or weighted).
1809
+ custom_weights: Optional overrides for point weights.
1810
+
1811
+ Returns:
1812
+ Dictionary with modality percentages summing to 100.
1813
+ """
1814
+ normalized_names = [name.lower() for name in celestial_points_names]
1815
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1816
+
1817
+ subject1_totals = _calculate_distribution_for_subject(
1818
+ subject1,
1819
+ normalized_names,
1820
+ _SIGN_TO_QUALITY,
1821
+ _QUALITY_KEYS,
1822
+ weight_lookup,
1823
+ fallback_weight,
1824
+ )
1825
+ subject2_totals = _calculate_distribution_for_subject(
1826
+ subject2,
1827
+ normalized_names,
1828
+ _SIGN_TO_QUALITY,
1829
+ _QUALITY_KEYS,
1830
+ weight_lookup,
1831
+ fallback_weight,
1832
+ )
1833
+
1834
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _QUALITY_KEYS}
1835
+ total_points = sum(combined_totals.values())
1836
+
1837
+ if total_points == 0:
1838
+ return {key: 0.0 for key in _QUALITY_KEYS}
1839
+
1840
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _QUALITY_KEYS}