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.
- kerykeion/__init__.py +58 -141
- kerykeion/aspects/__init__.py +14 -0
- kerykeion/aspects/aspects_factory.py +568 -0
- kerykeion/aspects/aspects_utils.py +164 -0
- kerykeion/astrological_subject_factory.py +1901 -0
- kerykeion/backword.py +820 -0
- kerykeion/chart_data_factory.py +552 -0
- kerykeion/charts/__init__.py +5 -0
- kerykeion/charts/chart_drawer.py +2794 -0
- kerykeion/charts/charts_utils.py +1840 -0
- kerykeion/charts/draw_planets.py +658 -0
- kerykeion/charts/templates/aspect_grid_only.xml +596 -0
- kerykeion/charts/templates/chart.xml +741 -0
- kerykeion/charts/templates/wheel_only.xml +653 -0
- kerykeion/charts/themes/black-and-white.css +148 -0
- kerykeion/charts/themes/classic.css +113 -0
- kerykeion/charts/themes/dark-high-contrast.css +159 -0
- kerykeion/charts/themes/dark.css +160 -0
- kerykeion/charts/themes/light.css +160 -0
- 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 +105 -61
- 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 +70 -0
- kerykeion/kr_types/chart_template_model.py +20 -0
- kerykeion/kr_types/kerykeion_exception.py +20 -0
- kerykeion/kr_types/kr_literals.py +20 -0
- kerykeion/kr_types/kr_models.py +20 -0
- kerykeion/kr_types/settings_models.py +20 -0
- kerykeion/planetary_return_factory.py +805 -0
- kerykeion/relationship_score_factory.py +301 -0
- kerykeion/report.py +779 -0
- 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 -0
- kerykeion/settings/chart_defaults.py +444 -0
- kerykeion/settings/config_constants.py +152 -0
- kerykeion/settings/kerykeion_settings.py +51 -0
- kerykeion/settings/translation_strings.py +1499 -0
- kerykeion/settings/translations.py +74 -0
- kerykeion/sweph/README.md +3 -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/seas_18.se1 +0 -0
- kerykeion/sweph/sefstars.txt +1602 -0
- kerykeion/transits_time_range_factory.py +302 -0
- kerykeion/utilities.py +762 -130
- kerykeion-5.1.9.dist-info/METADATA +1793 -0
- kerykeion-5.1.9.dist-info/RECORD +63 -0
- {kerykeion-3.1.1.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -2
- kerykeion-5.1.9.dist-info/licenses/LICENSE +661 -0
- kerykeion/aspects.py +0 -331
- kerykeion/charts/charts_svg.py +0 -1607
- kerykeion/charts/templates/basic.xml +0 -285
- kerykeion/charts/templates/extended.xml +0 -294
- kerykeion/kr.config.json +0 -464
- kerykeion/main.py +0 -595
- kerykeion/print_all_data.py +0 -44
- kerykeion/relationship_score.py +0 -219
- kerykeion/types.py +0 -190
- kerykeion-3.1.1.dist-info/METADATA +0 -204
- kerykeion-3.1.1.dist-info/RECORD +0 -17
- 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"  {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"  {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}
|