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.
- kerykeion/__init__.py +56 -11
- kerykeion/aspects/__init__.py +7 -4
- kerykeion/aspects/aspects_factory.py +568 -0
- kerykeion/aspects/aspects_utils.py +86 -13
- kerykeion/astrological_subject_factory.py +1901 -0
- kerykeion/backword.py +820 -0
- kerykeion/chart_data_factory.py +552 -0
- kerykeion/charts/__init__.py +2 -2
- kerykeion/charts/chart_drawer.py +2794 -0
- kerykeion/charts/charts_utils.py +1066 -309
- kerykeion/charts/draw_planets.py +602 -351
- kerykeion/charts/templates/aspect_grid_only.xml +337 -193
- kerykeion/charts/templates/chart.xml +441 -240
- kerykeion/charts/templates/wheel_only.xml +365 -211
- kerykeion/charts/themes/black-and-white.css +148 -0
- kerykeion/charts/themes/classic.css +107 -76
- kerykeion/charts/themes/dark-high-contrast.css +145 -107
- kerykeion/charts/themes/dark.css +146 -107
- kerykeion/charts/themes/light.css +146 -103
- kerykeion/charts/themes/strawberry.css +158 -0
- kerykeion/composite_subject_factory.py +408 -0
- kerykeion/ephemeris_data_factory.py +443 -0
- kerykeion/fetch_geonames.py +81 -21
- kerykeion/house_comparison/__init__.py +6 -0
- kerykeion/house_comparison/house_comparison_factory.py +103 -0
- kerykeion/house_comparison/house_comparison_utils.py +126 -0
- kerykeion/kr_types/__init__.py +66 -6
- kerykeion/kr_types/chart_template_model.py +20 -0
- kerykeion/kr_types/kerykeion_exception.py +15 -9
- kerykeion/kr_types/kr_literals.py +14 -106
- kerykeion/kr_types/kr_models.py +14 -179
- kerykeion/kr_types/settings_models.py +15 -152
- kerykeion/planetary_return_factory.py +805 -0
- kerykeion/relationship_score_factory.py +301 -0
- kerykeion/report.py +750 -65
- kerykeion/schemas/__init__.py +106 -0
- kerykeion/schemas/chart_template_model.py +367 -0
- kerykeion/schemas/kerykeion_exception.py +20 -0
- kerykeion/schemas/kr_literals.py +181 -0
- kerykeion/schemas/kr_models.py +603 -0
- kerykeion/schemas/settings_models.py +188 -0
- kerykeion/settings/__init__.py +20 -1
- kerykeion/settings/chart_defaults.py +444 -0
- kerykeion/settings/config_constants.py +152 -0
- kerykeion/settings/kerykeion_settings.py +36 -61
- kerykeion/settings/translation_strings.py +1499 -0
- kerykeion/settings/translations.py +74 -0
- kerykeion/sweph/ast136/s136108s.se1 +0 -0
- kerykeion/sweph/ast136/s136199s.se1 +0 -0
- kerykeion/sweph/ast136/s136472s.se1 +0 -0
- kerykeion/sweph/ast28/se28978s.se1 +0 -0
- kerykeion/sweph/ast50/se50000s.se1 +0 -0
- kerykeion/sweph/ast90/se90377s.se1 +0 -0
- kerykeion/sweph/ast90/se90482s.se1 +0 -0
- kerykeion/sweph/sefstars.txt +1602 -0
- kerykeion/transits_time_range_factory.py +302 -0
- kerykeion/utilities.py +626 -125
- kerykeion-5.1.9.dist-info/METADATA +1793 -0
- kerykeion-5.1.9.dist-info/RECORD +63 -0
- {kerykeion-4.18.3.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -1
- kerykeion/aspects/natal_aspects.py +0 -143
- kerykeion/aspects/synastry_aspects.py +0 -113
- kerykeion/astrological_subject.py +0 -818
- kerykeion/charts/kerykeion_chart_svg.py +0 -894
- kerykeion/enums.py +0 -51
- kerykeion/ephemeris_data.py +0 -178
- kerykeion/kr_types/chart_types.py +0 -88
- kerykeion/relationship_score/__init__.py +0 -2
- kerykeion/relationship_score/relationship_score.py +0 -175
- kerykeion/relationship_score/relationship_score_factory.py +0 -275
- kerykeion/settings/kr.config.json +0 -721
- kerykeion-4.18.3.dist-info/LICENSE +0 -661
- kerykeion-4.18.3.dist-info/METADATA +0 -396
- kerykeion-4.18.3.dist-info/RECORD +0 -42
- kerykeion-4.18.3.dist-info/entry_points.txt +0 -3
- /LICENSE → /kerykeion-5.1.9.dist-info/licenses/LICENSE +0 -0
kerykeion/charts/charts_utils.py
CHANGED
|
@@ -1,9 +1,230 @@
|
|
|
1
1
|
import math
|
|
2
2
|
import datetime
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
from kerykeion.
|
|
6
|
-
from kerykeion.
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 +=
|
|
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 +=
|
|
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 +=
|
|
876
|
+
path += "</g>"
|
|
671
877
|
|
|
672
|
-
# Adjust dropin based on chart type
|
|
673
|
-
|
|
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 +=
|
|
890
|
+
path += "</g>"
|
|
681
891
|
|
|
682
892
|
# Add the house number text for the first subject
|
|
683
|
-
path +=
|
|
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 +=
|
|
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:
|
|
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
|
-
-
|
|
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]
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
773
|
-
degrees_between_sun_and_moon: float
|
|
774
|
-
|
|
775
|
-
) -> str:
|
|
1025
|
+
def calculate_moon_phase_chart_params(
|
|
1026
|
+
degrees_between_sun_and_moon: float
|
|
1027
|
+
) -> dict:
|
|
776
1028
|
"""
|
|
777
|
-
|
|
1029
|
+
Calculate normalized parameters used by the moon phase icon.
|
|
778
1030
|
|
|
779
1031
|
Parameters:
|
|
780
|
-
- degrees_between_sun_and_moon (float): The
|
|
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
|
-
-
|
|
1035
|
+
- dict: Normalized phase data (angle, illuminated fraction, shadow ellipse radius).
|
|
788
1036
|
"""
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
#
|
|
829
|
-
|
|
1047
|
+
# Guard against floating point spillover outside [0, 1].
|
|
1048
|
+
illuminated_fraction = max(0.0, min(1.0, illuminated_fraction))
|
|
830
1049
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
|
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
|
-
-
|
|
858
|
-
-
|
|
859
|
-
-
|
|
860
|
-
-
|
|
861
|
-
-
|
|
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"  {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
|
-
|
|
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"  {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
|
|
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
|
-
|
|
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
|
|
922
|
-
subject_name
|
|
923
|
-
available_kerykeion_celestial_points
|
|
924
|
-
chart_type
|
|
925
|
-
celestial_point_language
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
1160
|
+
SVG string for the main planet grid wrapped in a `<g>`.
|
|
932
1161
|
"""
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
raise KerykeionException("second_subject_available_kerykeion_celestial_points is None")
|
|
1205
|
+
# Close the wrapper group
|
|
1206
|
+
svg_output += "</g>"
|
|
969
1207
|
|
|
970
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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
|
|
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}
|