kerykeion 3.1.1__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 (75) hide show
  1. kerykeion/__init__.py +58 -141
  2. kerykeion/aspects/__init__.py +14 -0
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +164 -0
  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 +5 -0
  9. kerykeion/charts/chart_drawer.py +2794 -0
  10. kerykeion/charts/charts_utils.py +1840 -0
  11. kerykeion/charts/draw_planets.py +658 -0
  12. kerykeion/charts/templates/aspect_grid_only.xml +596 -0
  13. kerykeion/charts/templates/chart.xml +741 -0
  14. kerykeion/charts/templates/wheel_only.xml +653 -0
  15. kerykeion/charts/themes/black-and-white.css +148 -0
  16. kerykeion/charts/themes/classic.css +113 -0
  17. kerykeion/charts/themes/dark-high-contrast.css +159 -0
  18. kerykeion/charts/themes/dark.css +160 -0
  19. kerykeion/charts/themes/light.css +160 -0
  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 +105 -61
  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 +70 -0
  28. kerykeion/kr_types/chart_template_model.py +20 -0
  29. kerykeion/kr_types/kerykeion_exception.py +20 -0
  30. kerykeion/kr_types/kr_literals.py +20 -0
  31. kerykeion/kr_types/kr_models.py +20 -0
  32. kerykeion/kr_types/settings_models.py +20 -0
  33. kerykeion/planetary_return_factory.py +805 -0
  34. kerykeion/relationship_score_factory.py +301 -0
  35. kerykeion/report.py +779 -0
  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 -0
  43. kerykeion/settings/chart_defaults.py +444 -0
  44. kerykeion/settings/config_constants.py +152 -0
  45. kerykeion/settings/kerykeion_settings.py +51 -0
  46. kerykeion/settings/translation_strings.py +1499 -0
  47. kerykeion/settings/translations.py +74 -0
  48. kerykeion/sweph/README.md +3 -0
  49. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  50. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  51. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  52. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  53. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  54. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  55. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  56. kerykeion/sweph/seas_18.se1 +0 -0
  57. kerykeion/sweph/sefstars.txt +1602 -0
  58. kerykeion/transits_time_range_factory.py +302 -0
  59. kerykeion/utilities.py +762 -130
  60. kerykeion-5.1.9.dist-info/METADATA +1793 -0
  61. kerykeion-5.1.9.dist-info/RECORD +63 -0
  62. {kerykeion-3.1.1.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -2
  63. kerykeion-5.1.9.dist-info/licenses/LICENSE +661 -0
  64. kerykeion/aspects.py +0 -331
  65. kerykeion/charts/charts_svg.py +0 -1607
  66. kerykeion/charts/templates/basic.xml +0 -285
  67. kerykeion/charts/templates/extended.xml +0 -294
  68. kerykeion/kr.config.json +0 -464
  69. kerykeion/main.py +0 -595
  70. kerykeion/print_all_data.py +0 -44
  71. kerykeion/relationship_score.py +0 -219
  72. kerykeion/types.py +0 -190
  73. kerykeion-3.1.1.dist-info/METADATA +0 -204
  74. kerykeion-3.1.1.dist-info/RECORD +0 -17
  75. kerykeion-3.1.1.dist-info/top_level.txt +0 -1
@@ -0,0 +1,1840 @@
1
+ import math
2
+ import datetime
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
+
228
+
229
+
230
+ def get_decoded_kerykeion_celestial_point_name(input_planet_name: str, celestial_point_language: KerykeionLanguageCelestialPointModel) -> str:
231
+ """
232
+ Decode the given celestial point name based on the provided language model.
233
+
234
+ Args:
235
+ input_planet_name (str): The name of the celestial point to decode.
236
+ celestial_point_language (KerykeionLanguageCelestialPointModel): The language model containing celestial point names.
237
+
238
+ Returns:
239
+ str: The decoded celestial point name.
240
+ """
241
+
242
+
243
+ # Get the language model keys
244
+ language_keys = celestial_point_language.model_dump().keys()
245
+
246
+ # Check if the input planet name exists in the language model
247
+ if input_planet_name in language_keys:
248
+ return celestial_point_language[input_planet_name]
249
+ else:
250
+ raise KerykeionException(f"Celestial point {input_planet_name} not found in language model.")
251
+
252
+
253
+ def decHourJoin(inH: int, inM: int, inS: int) -> float:
254
+ """Join hour, minutes, seconds, timezone integer to hour float.
255
+
256
+ Args:
257
+ - inH (int): hour
258
+ - inM (int): minutes
259
+ - inS (int): seconds
260
+ Returns:
261
+ float: hour in float format
262
+ """
263
+
264
+ dh = float(inH)
265
+ dm = float(inM) / 60
266
+ ds = float(inS) / 3600
267
+ output = dh + dm + ds
268
+ return output
269
+
270
+
271
+ def degreeDiff(a: Union[int, float], b: Union[int, float]) -> float:
272
+ """Calculate the smallest difference between two angles in degrees.
273
+
274
+ Args:
275
+ a (int | float): first angle in degrees
276
+ b (int | float): second angle in degrees
277
+
278
+ Returns:
279
+ float: smallest difference between a and b (0 to 180 degrees)
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
296
+
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
308
+
309
+
310
+ def offsetToTz(datetime_offset: Union[datetime.timedelta, None]) -> float:
311
+ """Convert datetime offset to float in hours.
312
+
313
+ Args:
314
+ - datetime_offset (datetime.timedelta): datetime offset
315
+
316
+ Returns:
317
+ - float: offset in hours
318
+ """
319
+
320
+ if datetime_offset is None:
321
+ raise KerykeionException("datetime_offset is None")
322
+
323
+ # days to hours
324
+ dh = float(datetime_offset.days * 24)
325
+ # seconds to hours
326
+ sh = float(datetime_offset.seconds / 3600.0)
327
+ # total hours
328
+ output = dh + sh
329
+ return output
330
+
331
+
332
+ def sliceToX(slice: Union[int, float], radius: Union[int, float], offset: Union[int, float]) -> float:
333
+ """Calculates the x-coordinate of a point on a circle based on the slice, radius, and offset.
334
+
335
+ Args:
336
+ - slice (int | float): Represents the
337
+ slice of the circle to calculate the x-coordinate for.
338
+ It must be between 0 and 11 (inclusive).
339
+ - radius (int | float): Represents the radius of the circle.
340
+ - offset (int | float): Represents the offset in degrees.
341
+ It must be between 0 and 360 (inclusive).
342
+
343
+ Returns:
344
+ float: The x-coordinate of the point on the circle.
345
+
346
+ Example:
347
+ >>> import math
348
+ >>> sliceToX(3, 5, 45)
349
+ 2.5000000000000018
350
+ """
351
+
352
+ plus = (math.pi * offset) / 180
353
+ radial = ((math.pi / 6) * slice) + plus
354
+ return radius * (math.cos(radial) + 1)
355
+
356
+
357
+ def sliceToY(slice: Union[int, float], r: Union[int, float], offset: Union[int, float]) -> float:
358
+ """Calculates the y-coordinate of a point on a circle based on the slice, radius, and offset.
359
+
360
+ Args:
361
+ - slice (int | float): Represents the slice of the circle to calculate
362
+ the y-coordinate for. It must be between 0 and 11 (inclusive).
363
+ - r (int | float): Represents the radius of the circle.
364
+ - offset (int | float): Represents the offset in degrees.
365
+ It must be between 0 and 360 (inclusive).
366
+
367
+ Returns:
368
+ float: The y-coordinate of the point on the circle.
369
+
370
+ Example:
371
+ >>> import math
372
+ >>> __sliceToY(3, 5, 45)
373
+ -4.330127018922194
374
+ """
375
+ plus = (math.pi * offset) / 180
376
+ radial = ((math.pi / 6) * slice) + plus
377
+ return r * ((math.sin(radial) / -1) + 1)
378
+
379
+
380
+ def draw_zodiac_slice(
381
+ c1: Union[int, float],
382
+ chart_type: ChartType,
383
+ seventh_house_degree_ut: Union[int, float],
384
+ num: int,
385
+ r: Union[int, float],
386
+ style: str,
387
+ type: str,
388
+ ) -> str:
389
+ """Draws a zodiac slice based on the given parameters.
390
+
391
+ Args:
392
+ - c1 (Union[int, float]): The value of c1.
393
+ - chart_type (ChartType): The type of chart.
394
+ - seventh_house_degree_ut (Union[int, float]): The degree of the seventh house.
395
+ - num (int): The number of the sign. Note: In OpenAstro it did refer to self.zodiac,
396
+ which is a list of the signs in order, starting with Aries. Eg:
397
+ {"name": "Ari", "element": "fire"}
398
+ - r (Union[int, float]): The value of r.
399
+ - style (str): The CSS inline style.
400
+ - type (str): The type ?. In OpenAstro, it was the symbol of the sign. Eg: "Ari".
401
+ self.zodiac[i]["name"]
402
+
403
+ Returns:
404
+ - str: The zodiac slice and symbol as an SVG path.
405
+ """
406
+
407
+ # pie slices
408
+ offset = 360 - seventh_house_degree_ut
409
+ # check transit
410
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
411
+ dropin: Union[int, float] = 0
412
+ else:
413
+ dropin = c1
414
+ slice = f'<path d="M{str(r)},{str(r)} L{str(dropin + sliceToX(num, r - dropin, offset))},{str(dropin + sliceToY(num, r - dropin, offset))} A{str(r - dropin)},{str(r - dropin)} 0 0,0 {str(dropin + sliceToX(num + 1, r - dropin, offset))},{str(dropin + sliceToY(num + 1, r - dropin, offset))} z" style="{style}"/>'
415
+
416
+ # symbols
417
+ offset = offset + 15
418
+ # check transit
419
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
420
+ dropin = 54
421
+ else:
422
+ dropin = 18 + c1
423
+ sign = f'<g transform="translate(-16,-16)"><use x="{str(dropin + sliceToX(num, r - dropin, offset))}" y="{str(dropin + sliceToY(num, r - dropin, offset))}" xlink:href="#{type}" /></g>'
424
+
425
+ return slice + "" + sign
426
+
427
+
428
+ def convert_latitude_coordinate_to_string(coord: Union[int, float], north_label: str, south_label: str) -> str:
429
+ """Converts a floating point latitude to string with
430
+ degree, minutes and seconds and the appropriate sign
431
+ (north or south). Eg. 52.1234567 -> 52°7'25" N
432
+
433
+ Args:
434
+ - coord (float | int): latitude in floating or integer format
435
+ - north_label (str): String label for north
436
+ - south_label (str): String label for south
437
+ Returns:
438
+ - str: latitude in string format with degree, minutes,
439
+ seconds and sign (N/S)
440
+ """
441
+
442
+ sign = north_label
443
+ if coord < 0.0:
444
+ sign = south_label
445
+ coord = abs(coord)
446
+ deg = int(coord)
447
+ min = int((float(coord) - deg) * 60)
448
+ sec = int(round(float(((float(coord) - deg) * 60) - min) * 60.0))
449
+ return f"{deg}°{min}'{sec}\" {sign}"
450
+
451
+
452
+ def convert_longitude_coordinate_to_string(coord: Union[int, float], east_label: str, west_label: str) -> str:
453
+ """Converts a floating point longitude to string with
454
+ degree, minutes and seconds and the appropriate sign
455
+ (east or west). Eg. 52.1234567 -> 52°7'25" E
456
+
457
+ Args:
458
+ - coord (float|int): longitude in floating point format
459
+ - east_label (str): String label for east
460
+ - west_label (str): String label for west
461
+ Returns:
462
+ str: longitude in string format with degree, minutes,
463
+ seconds and sign (E/W)
464
+ """
465
+
466
+ sign = east_label
467
+ if coord < 0.0:
468
+ sign = west_label
469
+ coord = abs(coord)
470
+ deg = int(coord)
471
+ min = int((float(coord) - deg) * 60)
472
+ sec = int(round(float(((float(coord) - deg) * 60) - min) * 60.0))
473
+ return f"{deg}°{min}'{sec}\" {sign}"
474
+
475
+
476
+ def draw_aspect_line(
477
+ r: Union[int, float],
478
+ ar: Union[int, float],
479
+ aspect: Union[AspectModel, dict],
480
+ color: str,
481
+ seventh_house_degree_ut: Union[int, float],
482
+ ) -> str:
483
+ """Draws svg aspects: ring, aspect ring, degreeA degreeB
484
+
485
+ Args:
486
+ - r (Union[int, float]): The value of r.
487
+ - ar (Union[int, float]): The value of ar.
488
+ - aspect_dict (dict): The aspect dictionary.
489
+ - color (str): The color of the aspect.
490
+ - seventh_house_degree_ut (Union[int, float]): The degree of the seventh house.
491
+
492
+ Returns:
493
+ str: The SVG line element as a string.
494
+ """
495
+
496
+ if isinstance(aspect, dict):
497
+ aspect = AspectModel(**aspect)
498
+
499
+ first_offset = (int(seventh_house_degree_ut) / -1) + int(aspect["p1_abs_pos"])
500
+ x1 = sliceToX(0, ar, first_offset) + (r - ar)
501
+ y1 = sliceToY(0, ar, first_offset) + (r - ar)
502
+
503
+ second_offset = (int(seventh_house_degree_ut) / -1) + int(aspect["p2_abs_pos"])
504
+ x2 = sliceToX(0, ar, second_offset) + (r - ar)
505
+ y2 = sliceToY(0, ar, second_offset) + (r - ar)
506
+
507
+ return (
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"]}">'
509
+ f'<line class="aspect" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {color}; stroke-width: 1; stroke-opacity: .9;"/>'
510
+ f"</g>"
511
+ )
512
+
513
+
514
+ def convert_decimal_to_degree_string(dec: float, format_type: Literal["1", "2", "3"] = "3") -> str:
515
+ """
516
+ Converts a decimal float to a degrees string in the specified format.
517
+
518
+ Args:
519
+ dec (float): The decimal float to convert.
520
+ format_type (str): The format type:
521
+ - "1": a°
522
+ - "2": a°b'
523
+ - "3": a°b'c" (default)
524
+
525
+ Returns:
526
+ str: The degrees string in the specified format.
527
+ """
528
+ # Ensure the input is a float
529
+ dec = float(dec)
530
+
531
+ # Calculate degrees, minutes, and seconds
532
+ degrees = int(dec)
533
+ minutes = int((dec - degrees) * 60)
534
+ seconds = int(round((dec - degrees - minutes / 60) * 3600))
535
+
536
+ # Format the output based on the specified type
537
+ if format_type == "1":
538
+ return f"{degrees}°"
539
+ elif format_type == "2":
540
+ return f"{degrees}°{minutes:02d}'"
541
+ elif format_type == "3":
542
+ return f"{degrees}°{minutes:02d}'{seconds:02d}\""
543
+
544
+
545
+ def draw_transit_ring_degree_steps(r: Union[int, float], seventh_house_degree_ut: Union[int, float]) -> str:
546
+ """Draws the transit ring degree steps.
547
+
548
+ Args:
549
+ - r (Union[int, float]): The value of r.
550
+ - seventh_house_degree_ut (Union[int, float]): The degree of the seventh house.
551
+
552
+ Returns:
553
+ str: The SVG path of the transit ring degree steps.
554
+ """
555
+
556
+ out = '<g id="transitRingDegreeSteps">'
557
+ for i in range(72):
558
+ offset = float(i * 5) - seventh_house_degree_ut
559
+ if offset < 0:
560
+ offset = offset + 360.0
561
+ elif offset > 360:
562
+ offset = offset - 360.0
563
+ x1 = sliceToX(0, r, offset)
564
+ y1 = sliceToY(0, r, offset)
565
+ x2 = sliceToX(0, r + 2, offset) - 2
566
+ y2 = sliceToY(0, r + 2, offset) - 2
567
+ out += f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: #F00; stroke-width: 1px; stroke-opacity:.9;"/>'
568
+ out += "</g>"
569
+
570
+ return out
571
+
572
+
573
+ def draw_degree_ring(
574
+ r: Union[int, float], c1: Union[int, float], seventh_house_degree_ut: Union[int, float], stroke_color: str
575
+ ) -> str:
576
+ """Draws the degree ring.
577
+
578
+ Args:
579
+ - r (Union[int, float]): The value of r.
580
+ - c1 (Union[int, float]): The value of c1.
581
+ - seventh_house_degree_ut (Union[int, float]): The degree of the seventh house.
582
+ - stroke_color (str): The color of the stroke.
583
+
584
+ Returns:
585
+ str: The SVG path of the degree ring.
586
+ """
587
+ out = '<g id="degreeRing">'
588
+ for i in range(72):
589
+ offset = float(i * 5) - seventh_house_degree_ut
590
+ if offset < 0:
591
+ offset = offset + 360.0
592
+ elif offset > 360:
593
+ offset = offset - 360.0
594
+ x1 = sliceToX(0, r - c1, offset) + c1
595
+ y1 = sliceToY(0, r - c1, offset) + c1
596
+ x2 = sliceToX(0, r + 2 - c1, offset) - 2 + c1
597
+ y2 = sliceToY(0, r + 2 - c1, offset) - 2 + c1
598
+
599
+ out += f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {stroke_color}; stroke-width: 1px; stroke-opacity:.9;"/>'
600
+ out += "</g>"
601
+
602
+ return out
603
+
604
+
605
+ def draw_transit_ring(r: Union[int, float], paper_1_color: str, zodiac_transit_ring_3_color: str) -> str:
606
+ """
607
+ Draws the transit ring.
608
+
609
+ Args:
610
+ - r (Union[int, float]): The value of r.
611
+ - paper_1_color (str): The color of paper 1.
612
+ - zodiac_transit_ring_3_color (str): The color of the zodiac transit ring
613
+
614
+ Returns:
615
+ str: The SVG path of the transit ring.
616
+ """
617
+ radius_offset = 18
618
+
619
+ out = f'<circle cx="{r}" cy="{r}" r="{r - radius_offset}" style="fill: none; stroke: {paper_1_color}; stroke-width: 36px; stroke-opacity: .4;"/>'
620
+ out += f'<circle cx="{r}" cy="{r}" r="{r}" style="fill: none; stroke: {zodiac_transit_ring_3_color}; stroke-width: 1px; stroke-opacity: .6;"/>'
621
+
622
+ return out
623
+
624
+
625
+ def draw_first_circle(
626
+ r: Union[int, float], stroke_color: str, chart_type: ChartType, c1: Union[int, float, None] = None
627
+ ) -> str:
628
+ """
629
+ Draws the first circle.
630
+
631
+ Args:
632
+ - r (Union[int, float]): The value of r.
633
+ - color (str): The color of the circle.
634
+ - chart_type (ChartType): The type of chart.
635
+ - c1 (Union[int, float]): The value of c1.
636
+
637
+ Returns:
638
+ str: The SVG path of the first circle.
639
+ """
640
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
641
+ return f'<circle cx="{r}" cy="{r}" r="{r - 36}" style="fill: none; stroke: {stroke_color}; stroke-width: 1px; stroke-opacity:.4;" />'
642
+ else:
643
+ if c1 is None:
644
+ raise KerykeionException("c1 is None")
645
+
646
+ return (
647
+ f'<circle cx="{r}" cy="{r}" r="{r - c1}" style="fill: none; stroke: {stroke_color}; stroke-width: 1px; " />'
648
+ )
649
+
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
+
666
+ def draw_second_circle(
667
+ r: Union[int, float], stroke_color: str, fill_color: str, chart_type: ChartType, c2: Union[int, float, None] = None
668
+ ) -> str:
669
+ """
670
+ Draws the second circle.
671
+
672
+ Args:
673
+ - r (Union[int, float]): The value of r.
674
+ - stroke_color (str): The color of the stroke.
675
+ - fill_color (str): The color of the fill.
676
+ - chart_type (ChartType): The type of chart.
677
+ - c2 (Union[int, float]): The value of c2.
678
+
679
+ Returns:
680
+ str: The SVG path of the second circle.
681
+ """
682
+
683
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
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" />'
685
+
686
+ else:
687
+ if c2 is None:
688
+ raise KerykeionException("c2 is None")
689
+
690
+ return f'<circle cx="{r}" cy="{r}" r="{r - c2}" style="fill: {fill_color}; fill-opacity:.2; stroke: {stroke_color}; stroke-opacity:.4; stroke-width: 1px" />'
691
+
692
+
693
+ def draw_third_circle(
694
+ radius: Union[int, float],
695
+ stroke_color: str,
696
+ fill_color: str,
697
+ chart_type: ChartType,
698
+ c3: Union[int, float]
699
+ ) -> str:
700
+ """
701
+ Draws the third circle in an SVG chart.
702
+
703
+ Parameters:
704
+ - radius (Union[int, float]): The radius of the circle.
705
+ - stroke_color (str): The stroke color of the circle.
706
+ - fill_color (str): The fill color of the circle.
707
+ - chart_type (ChartType): The type of the chart.
708
+ - c3 (Union[int, float, None], optional): The radius adjustment for non-Synastry and non-Transit charts.
709
+
710
+ Returns:
711
+ - str: The SVG element as a string.
712
+ """
713
+ if chart_type in {"Synastry", "Transit", "DualReturnChart"}:
714
+ # For Synastry and Transit charts, use a fixed radius adjustment of 160
715
+ return f'<circle cx="{radius}" cy="{radius}" r="{radius - 160}" style="fill: {fill_color}; fill-opacity:.8; stroke: {stroke_color}; stroke-width: 1px" />'
716
+
717
+ else:
718
+ return f'<circle cx="{radius}" cy="{radius}" r="{radius - c3}" style="fill: {fill_color}; fill-opacity:.8; stroke: {stroke_color}; stroke-width: 1px" />'
719
+
720
+
721
+ def draw_aspect_grid(
722
+ stroke_color: str,
723
+ available_planets: list,
724
+ aspects: list,
725
+ x_start: int = 510,
726
+ y_start: int = 468,
727
+ ) -> str:
728
+ """
729
+ Draws the aspect grid for the given planets and aspects.
730
+
731
+ Args:
732
+ stroke_color (str): The color of the stroke.
733
+ available_planets (list): List of all planets. Only planets with "is_active" set to True will be used.
734
+ aspects (list): List of aspects.
735
+ x_start (int): The x-coordinate starting point.
736
+ y_start (int): The y-coordinate starting point.
737
+
738
+ Returns:
739
+ str: SVG string representing the aspect grid.
740
+ """
741
+ svg_output = ""
742
+ style = f"stroke:{stroke_color}; stroke-width: 1px; stroke-width: 0.5px; fill:none"
743
+ box_size = 14
744
+
745
+ # Filter active planets
746
+ active_planets = [planet for planet in available_planets if planet["is_active"]]
747
+
748
+ # Reverse the list of active planets for the first iteration
749
+ reversed_planets = active_planets[::-1]
750
+
751
+ for index, planet_a in enumerate(reversed_planets):
752
+ # Draw the grid box for the planet
753
+ svg_output += f'<rect kr:node="AspectsGridRect" x="{x_start}" y="{y_start}" width="{box_size}" height="{box_size}" style="{style}"/>'
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"]}" />'
755
+
756
+ # Update the starting coordinates for the next box
757
+ x_start += box_size
758
+ y_start -= box_size
759
+
760
+ # Coordinates for the aspect symbols
761
+ x_aspect = x_start
762
+ y_aspect = y_start + box_size
763
+
764
+ # Iterate over the remaining planets
765
+ for planet_b in reversed_planets[index + 1:]:
766
+ # Draw the grid box for the aspect
767
+ svg_output += f'<rect kr:node="AspectsGridRect" x="{x_aspect}" y="{y_aspect}" width="{box_size}" height="{box_size}" style="{style}"/>'
768
+ x_aspect += box_size
769
+
770
+ # Check for aspects between the planets
771
+ for aspect in aspects:
772
+ if (aspect["p1"] == planet_a["id"] and aspect["p2"] == planet_b["id"]) or (
773
+ aspect["p1"] == planet_b["id"] and aspect["p2"] == planet_a["id"]
774
+ ):
775
+ svg_output += f'<use x="{x_aspect - box_size + 1}" y="{y_aspect + 1}" xlink:href="#orb{aspect["aspect_degrees"]}" />'
776
+
777
+ return svg_output
778
+
779
+
780
+ def draw_houses_cusps_and_text_number(
781
+ r: Union[int, float],
782
+ first_subject_houses_list: list[KerykeionPointModel],
783
+ standard_house_cusp_color: str,
784
+ first_house_color: str,
785
+ tenth_house_color: str,
786
+ seventh_house_color: str,
787
+ fourth_house_color: str,
788
+ c1: Union[int, float],
789
+ c3: Union[int, float],
790
+ chart_type: ChartType,
791
+ second_subject_houses_list: Union[list[KerykeionPointModel], None] = None,
792
+ transit_house_cusp_color: Union[str, None] = None,
793
+ external_view: bool = False,
794
+ ) -> str:
795
+ """
796
+ Draws the houses cusps and text numbers for a given chart type.
797
+
798
+ Parameters:
799
+ - r: Radius of the chart.
800
+ - first_subject_houses_list: List of house for the first subject.
801
+ - standard_house_cusp_color: Default color for house cusps.
802
+ - first_house_color: Color for the first house cusp.
803
+ - tenth_house_color: Color for the tenth house cusp.
804
+ - seventh_house_color: Color for the seventh house cusp.
805
+ - fourth_house_color: Color for the fourth house cusp.
806
+ - c1: Offset for the first subject.
807
+ - c3: Offset for the third subject.
808
+ - chart_type: Type of the chart (e.g., Transit, Synastry).
809
+ - second_subject_houses_list: List of house for the second subject (optional).
810
+ - transit_house_cusp_color: Color for transit house cusps (optional).
811
+ - external_view: Whether to use external view mode for positioning (optional).
812
+
813
+ Returns:
814
+ - A string containing SVG elements for house cusps and numbers.
815
+ """
816
+
817
+ path = ""
818
+ xr = 12
819
+
820
+ for i in range(xr):
821
+ # Determine offsets based on chart type
822
+ dropin, roff, t_roff = (160, 72, 36) if chart_type in ["Transit", "Synastry", "DualReturnChart"] else (c3, c1, False)
823
+
824
+ # Calculate the offset for the current house cusp
825
+ offset = (int(first_subject_houses_list[int(xr / 2)].abs_pos) / -1) + int(first_subject_houses_list[i].abs_pos)
826
+
827
+ # Calculate the coordinates for the house cusp lines
828
+ x1 = sliceToX(0, (r - dropin), offset) + dropin
829
+ y1 = sliceToY(0, (r - dropin), offset) + dropin
830
+ x2 = sliceToX(0, r - roff, offset) + roff
831
+ y2 = sliceToY(0, r - roff, offset) + roff
832
+
833
+ # Calculate the text offset for the house number
834
+ next_index = (i + 1) % xr
835
+ text_offset = offset + int(
836
+ degreeDiff(first_subject_houses_list[next_index].abs_pos, first_subject_houses_list[i].abs_pos) / 2
837
+ )
838
+
839
+ # Determine the line color based on the house index
840
+ linecolor = {0: first_house_color, 9: tenth_house_color, 6: seventh_house_color, 3: fourth_house_color}.get(
841
+ i, standard_house_cusp_color
842
+ )
843
+
844
+ if chart_type in ["Transit", "Synastry", "DualReturnChart"]:
845
+ if second_subject_houses_list is None or transit_house_cusp_color is None:
846
+ raise KerykeionException("second_subject_houses_list_ut or transit_house_cusp_color is None")
847
+
848
+ # Calculate the offset for the second subject's house cusp
849
+ zeropoint = 360 - first_subject_houses_list[6].abs_pos
850
+ t_offset = (zeropoint + second_subject_houses_list[i].abs_pos) % 360
851
+
852
+ # Calculate the coordinates for the second subject's house cusp lines
853
+ t_x1 = sliceToX(0, (r - t_roff), t_offset) + t_roff
854
+ t_y1 = sliceToY(0, (r - t_roff), t_offset) + t_roff
855
+ t_x2 = sliceToX(0, r, t_offset)
856
+ t_y2 = sliceToY(0, r, t_offset)
857
+
858
+ # Calculate the text offset for the second subject's house number
859
+ t_text_offset = t_offset + int(
860
+ degreeDiff(second_subject_houses_list[next_index].abs_pos, second_subject_houses_list[i].abs_pos) / 2
861
+ )
862
+ t_linecolor = linecolor if i in [0, 9, 6, 3] else transit_house_cusp_color
863
+ xtext = sliceToX(0, (r - 8), t_text_offset) + 8
864
+ ytext = sliceToY(0, (r - 8), t_text_offset) + 8
865
+
866
+ # Add the house number text for the second subject
867
+ fill_opacity = "0" if chart_type == "Transit" else ".4"
868
+ path += '<g kr:node="HouseNumber">'
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>'
870
+ path += "</g>"
871
+
872
+ # Add the house cusp line for the second subject
873
+ stroke_opacity = "0" if chart_type == "Transit" else ".3"
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}">'
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};'/>"
876
+ path += "</g>"
877
+
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)
884
+ xtext = sliceToX(0, (r - dropin), text_offset) + dropin
885
+ ytext = sliceToY(0, (r - dropin), text_offset) + dropin
886
+
887
+ # Add the house cusp line for the first subject
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}">'
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;"/>'
890
+ path += "</g>"
891
+
892
+ # Add the house number text for the first subject
893
+ path += '<g kr:node="HouseNumber">'
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>'
895
+ path += "</g>"
896
+
897
+ return path
898
+
899
+
900
+ def draw_transit_aspect_list(
901
+ grid_title: str,
902
+ aspects_list: Union[list[AspectModel], list[dict]],
903
+ celestial_point_language: Union[KerykeionLanguageCelestialPointModel, 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,
911
+ ) -> str:
912
+ """
913
+ Generates the SVG output for the aspect transit grid.
914
+
915
+ Parameters:
916
+ - grid_title: Title of the grid.
917
+ - aspects_list: List of aspects.
918
+ - celestial_point_language: Dictionary containing the celestial point language data.
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).
926
+
927
+ Returns:
928
+ - A string containing the SVG path data for the aspect transit grid.
929
+ """
930
+
931
+ if isinstance(celestial_point_language, dict):
932
+ celestial_point_language = KerykeionLanguageCelestialPointModel(**celestial_point_language)
933
+
934
+ # If not instance of AspectModel, convert to AspectModel
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
952
+
953
+ inner_path = ""
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})">'
1018
+ out += f'<text y="-15" x="0" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 14px;">{grid_title}:</text>'
1019
+ out += inner_path
1020
+ out += "</g>"
1021
+
1022
+ return out
1023
+
1024
+
1025
+ def calculate_moon_phase_chart_params(
1026
+ degrees_between_sun_and_moon: float
1027
+ ) -> dict:
1028
+ """
1029
+ Calculate normalized parameters used by the moon phase icon.
1030
+
1031
+ Parameters:
1032
+ - degrees_between_sun_and_moon (float): The elongation between the sun and moon.
1033
+
1034
+ Returns:
1035
+ - dict: Normalized phase data (angle, illuminated fraction, shadow ellipse radius).
1036
+ """
1037
+ if not math.isfinite(degrees_between_sun_and_moon):
1038
+ raise KerykeionException(
1039
+ f"Invalid degree value: {degrees_between_sun_and_moon}"
1040
+ )
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
1046
+
1047
+ # Guard against floating point spillover outside [0, 1].
1048
+ illuminated_fraction = max(0.0, min(1.0, illuminated_fraction))
1049
+
1050
+ return {
1051
+ "phase_angle": phase_angle,
1052
+ "illuminated_fraction": illuminated_fraction,
1053
+ "shadow_ellipse_rx": 10.0 * cosine,
1054
+ }
1055
+
1056
+
1057
+ def draw_main_house_grid(
1058
+ main_subject_houses_list: list[KerykeionPointModel],
1059
+ house_cusp_generale_name_label: str = "Cusp",
1060
+ text_color: str = "#000000",
1061
+ x_position: int = 750,
1062
+ y_position: int = 30,
1063
+ ) -> str:
1064
+ """
1065
+ Generate SVG code for a grid of astrological houses for the main subject.
1066
+
1067
+ Parameters:
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.
1073
+
1074
+ Returns:
1075
+ - str: The SVG code for the grid of houses.
1076
+ """
1077
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1078
+
1079
+ line_increment = 10
1080
+ for i, house in enumerate(main_subject_houses_list):
1081
+ cusp_number = f"&#160;&#160;{i + 1}" if i < 9 else str(i + 1)
1082
+ svg_output += (
1083
+ f'<g transform="translate(0,{line_increment})">'
1084
+ f'<text text-anchor="end" x="40" style="fill:{text_color}; font-size: 10px;">{house_cusp_generale_name_label} {cusp_number}:</text>'
1085
+ f'<g transform="translate(40,-8)"><use transform="scale(0.3)" xlink:href="#{house["sign"]}" /></g>'
1086
+ f'<text x="53" style="fill:{text_color}; font-size: 10px;"> {convert_decimal_to_degree_string(house["position"])}</text>'
1087
+ f'</g>'
1088
+ )
1089
+ line_increment += 14
1090
+
1091
+ svg_output += "</g>"
1092
+ return svg_output
1093
+
1094
+
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
1128
+
1129
+ svg_output += "</g>"
1130
+ return svg_output
1131
+
1132
+
1133
+ def draw_main_planet_grid(
1134
+ planets_and_houses_grid_title: str,
1135
+ subject_name: str,
1136
+ available_kerykeion_celestial_points: list[KerykeionPointModel],
1137
+ chart_type: ChartType,
1138
+ celestial_point_language: KerykeionLanguageCelestialPointModel,
1139
+ text_color: str = "#000000",
1140
+ x_position: int = 645,
1141
+ y_position: int = 0,
1142
+ ) -> str:
1143
+ """
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.
1148
+
1149
+ Args:
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).
1158
+
1159
+ Returns:
1160
+ SVG string for the main planet grid wrapped in a `<g>`.
1161
+ """
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
1167
+
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
+ )
1178
+
1179
+ end_of_line = "</g>"
1180
+
1181
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1182
+
1183
+ for i, planet in enumerate(available_kerykeion_celestial_points):
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
+ )
1191
+
1192
+ svg_output += (
1193
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
1194
+ f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{decoded_name}</text>'
1195
+ f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{planet["name"]}" /></g>'
1196
+ f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(planet["position"])}</text>'
1197
+ f'<g transform="translate(60,-8)"><use transform="scale(0.3)" xlink:href="#{planet["sign"]}" /></g>'
1198
+ )
1199
+
1200
+ if planet["retrograde"]:
1201
+ svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1202
+
1203
+ svg_output += end_of_line
1204
+
1205
+ # Close the wrapper group
1206
+ svg_output += "</g>"
1207
+
1208
+ return svg_output
1209
+
1210
+
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
1288
+
1289
+
1290
+ # Close wrapper group
1291
+ svg_output += "</g>"
1292
+
1293
+ return svg_output
1294
+
1295
+
1296
+ def draw_transit_aspect_grid(
1297
+ stroke_color: str,
1298
+ available_planets: list,
1299
+ aspects: list,
1300
+ x_indent: int = 50,
1301
+ y_indent: int = 250,
1302
+ box_size: int = 14
1303
+ ) -> str:
1304
+ """
1305
+ Draws the aspect grid for the given planets and aspects. The default args value are specific for a stand alone
1306
+ aspect grid.
1307
+
1308
+ Args:
1309
+ stroke_color (str): The color of the stroke.
1310
+ available_planets (list): List of all planets. Only planets with "is_active" set to True will be used.
1311
+ aspects (list): List of aspects.
1312
+ x_indent (int): The initial x-coordinate starting point.
1313
+ y_indent (int): The initial y-coordinate starting point.
1314
+
1315
+ Returns:
1316
+ str: SVG string representing the aspect grid.
1317
+ """
1318
+ svg_output = ""
1319
+ style = f"stroke:{stroke_color}; stroke-width: 1px; stroke-width: 0.5px; fill:none"
1320
+ x_start = x_indent
1321
+ y_start = y_indent
1322
+
1323
+ # Filter active planets
1324
+ active_planets = [planet for planet in available_planets if planet["is_active"]]
1325
+
1326
+ # Reverse the list of active planets for the first iteration
1327
+ reversed_planets = active_planets[::-1]
1328
+ for index, planet_a in enumerate(reversed_planets):
1329
+ # Draw the grid box for the planet
1330
+ svg_output += f'<rect x="{x_start}" y="{y_start}" width="{box_size}" height="{box_size}" style="{style}"/>'
1331
+ 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"]}" />'
1332
+ x_start += box_size
1333
+
1334
+ x_start = x_indent - box_size
1335
+ y_start = y_indent - box_size
1336
+
1337
+ for index, planet_a in enumerate(reversed_planets):
1338
+ # Draw the grid box for the planet
1339
+ svg_output += f'<rect x="{x_start}" y="{y_start}" width="{box_size}" height="{box_size}" style="{style}"/>'
1340
+ 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"]}" />'
1341
+ y_start -= box_size
1342
+
1343
+ x_start = x_indent
1344
+ y_start = y_indent
1345
+ y_start = y_start - box_size
1346
+
1347
+ for index, planet_a in enumerate(reversed_planets):
1348
+ # Draw the grid box for the planet
1349
+ svg_output += f'<rect x="{x_start}" y="{y_start}" width="{box_size}" height="{box_size}" style="{style}"/>'
1350
+
1351
+ # Update the starting coordinates for the next box
1352
+ y_start -= box_size
1353
+
1354
+ # Coordinates for the aspect symbols
1355
+ x_aspect = x_start
1356
+ y_aspect = y_start + box_size
1357
+
1358
+ # Iterate over the remaining planets
1359
+ for planet_b in reversed_planets:
1360
+ # Draw the grid box for the aspect
1361
+ svg_output += f'<rect x="{x_aspect}" y="{y_aspect}" width="{box_size}" height="{box_size}" style="{style}"/>'
1362
+ x_aspect += box_size
1363
+
1364
+ # Check for aspects between the planets
1365
+ for aspect in aspects:
1366
+ if (aspect["p1"] == planet_a["id"] and aspect["p2"] == planet_b["id"]):
1367
+ svg_output += f'<use x="{x_aspect - box_size + 1}" y="{y_aspect + 1}" xlink:href="#orb{aspect["aspect_degrees"]}" />'
1368
+
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}