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

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

Potentially problematic release.


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

Files changed (76) hide show
  1. kerykeion/__init__.py +54 -11
  2. kerykeion/aspects/__init__.py +5 -2
  3. kerykeion/aspects/aspects_factory.py +569 -0
  4. kerykeion/aspects/aspects_utils.py +81 -8
  5. kerykeion/astrological_subject_factory.py +1897 -0
  6. kerykeion/backword.py +773 -0
  7. kerykeion/chart_data_factory.py +549 -0
  8. kerykeion/charts/chart_drawer.py +2601 -0
  9. kerykeion/charts/charts_utils.py +948 -177
  10. kerykeion/charts/draw_planets.py +602 -351
  11. kerykeion/charts/templates/aspect_grid_only.xml +328 -202
  12. kerykeion/charts/templates/chart.xml +432 -272
  13. kerykeion/charts/templates/wheel_only.xml +350 -214
  14. kerykeion/charts/themes/black-and-white.css +148 -0
  15. kerykeion/charts/themes/classic.css +107 -76
  16. kerykeion/charts/themes/dark-high-contrast.css +145 -107
  17. kerykeion/charts/themes/dark.css +146 -107
  18. kerykeion/charts/themes/light.css +146 -103
  19. kerykeion/charts/themes/strawberry.css +158 -0
  20. kerykeion/composite_subject_factory.py +253 -51
  21. kerykeion/ephemeris_data_factory.py +434 -0
  22. kerykeion/fetch_geonames.py +27 -8
  23. kerykeion/house_comparison/__init__.py +6 -0
  24. kerykeion/house_comparison/house_comparison_factory.py +103 -0
  25. kerykeion/house_comparison/house_comparison_utils.py +126 -0
  26. kerykeion/kr_types/__init__.py +66 -6
  27. kerykeion/kr_types/chart_template_model.py +20 -0
  28. kerykeion/kr_types/kerykeion_exception.py +15 -9
  29. kerykeion/kr_types/kr_literals.py +14 -132
  30. kerykeion/kr_types/kr_models.py +14 -318
  31. kerykeion/kr_types/settings_models.py +15 -203
  32. kerykeion/planetary_return_factory.py +805 -0
  33. kerykeion/relationship_score_factory.py +301 -0
  34. kerykeion/report.py +751 -64
  35. kerykeion/schemas/__init__.py +106 -0
  36. kerykeion/schemas/chart_template_model.py +367 -0
  37. kerykeion/schemas/kerykeion_exception.py +20 -0
  38. kerykeion/schemas/kr_literals.py +181 -0
  39. kerykeion/schemas/kr_models.py +605 -0
  40. kerykeion/schemas/settings_models.py +180 -0
  41. kerykeion/settings/__init__.py +20 -1
  42. kerykeion/settings/chart_defaults.py +444 -0
  43. kerykeion/settings/config_constants.py +117 -12
  44. kerykeion/settings/kerykeion_settings.py +31 -73
  45. kerykeion/settings/translation_strings.py +1479 -0
  46. kerykeion/settings/translations.py +74 -0
  47. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  48. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  50. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  51. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  52. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  54. kerykeion/sweph/sefstars.txt +1602 -0
  55. kerykeion/transits_time_range_factory.py +302 -0
  56. kerykeion/utilities.py +393 -114
  57. kerykeion-5.0.0.dist-info/METADATA +1176 -0
  58. kerykeion-5.0.0.dist-info/RECORD +63 -0
  59. {kerykeion-4.26.3.dist-info → kerykeion-5.0.0.dist-info}/WHEEL +1 -1
  60. kerykeion/aspects/natal_aspects.py +0 -172
  61. kerykeion/aspects/synastry_aspects.py +0 -124
  62. kerykeion/aspects/transits_time_range.py +0 -41
  63. kerykeion/astrological_subject.py +0 -841
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -1219
  65. kerykeion/enums.py +0 -57
  66. kerykeion/ephemeris_data.py +0 -242
  67. kerykeion/kr_types/chart_types.py +0 -95
  68. kerykeion/relationship_score/__init__.py +0 -2
  69. kerykeion/relationship_score/relationship_score.py +0 -175
  70. kerykeion/relationship_score/relationship_score_factory.py +0 -230
  71. kerykeion/settings/kr.config.json +0 -1258
  72. kerykeion/transits_time_range.py +0 -124
  73. kerykeion-4.26.3.dist-info/METADATA +0 -634
  74. kerykeion-4.26.3.dist-info/RECORD +0 -45
  75. kerykeion-4.26.3.dist-info/entry_points.txt +0 -3
  76. {kerykeion-4.26.3.dist-info → kerykeion-5.0.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,2601 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ This is part of Kerykeion (C) 2025 Giacomo Battaglia
4
+ """
5
+
6
+
7
+ import logging
8
+ from copy import deepcopy
9
+ from math import ceil
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from string import Template
13
+ from typing import Any, Mapping, Optional, Union, get_args
14
+
15
+ import swisseph as swe
16
+ from scour.scour import scourString
17
+
18
+ from kerykeion.house_comparison.house_comparison_factory import HouseComparisonFactory
19
+ from kerykeion.schemas import (
20
+ KerykeionException,
21
+ ChartType,
22
+ Sign,
23
+ ActiveAspect,
24
+ KerykeionPointModel,
25
+ )
26
+ from kerykeion.schemas import ChartTemplateModel
27
+ from kerykeion.schemas.kr_models import (
28
+ AstrologicalSubjectModel,
29
+ CompositeSubjectModel,
30
+ PlanetReturnModel,
31
+ )
32
+ from kerykeion.schemas.settings_models import (
33
+ KerykeionSettingsCelestialPointModel,
34
+ KerykeionLanguageModel,
35
+ )
36
+ from kerykeion.schemas.kr_literals import (
37
+ KerykeionChartTheme,
38
+ KerykeionChartLanguage,
39
+ AstrologicalPoint,
40
+ )
41
+ from kerykeion.schemas.kr_models import ChartDataModel
42
+ from kerykeion.settings import LANGUAGE_SETTINGS
43
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
44
+ from kerykeion.settings.translations import get_translations, load_language_settings
45
+ from kerykeion.charts.charts_utils import (
46
+ draw_zodiac_slice,
47
+ convert_latitude_coordinate_to_string,
48
+ convert_longitude_coordinate_to_string,
49
+ draw_aspect_line,
50
+ draw_transit_ring_degree_steps,
51
+ draw_degree_ring,
52
+ draw_transit_ring,
53
+ draw_background_circle,
54
+ draw_first_circle,
55
+ draw_house_comparison_grid,
56
+ draw_second_circle,
57
+ draw_third_circle,
58
+ draw_aspect_grid,
59
+ draw_houses_cusps_and_text_number,
60
+ draw_transit_aspect_list,
61
+ draw_transit_aspect_grid,
62
+ draw_single_house_comparison_grid,
63
+ makeLunarPhase,
64
+ draw_main_house_grid,
65
+ draw_secondary_house_grid,
66
+ draw_main_planet_grid,
67
+ draw_secondary_planet_grid,
68
+ format_location_string,
69
+ format_datetime_with_timezone
70
+ )
71
+ from kerykeion.charts.draw_planets import draw_planets
72
+ from kerykeion.utilities import get_houses_list, inline_css_variables_in_svg, distribute_percentages_to_100
73
+ from kerykeion.settings.chart_defaults import (
74
+ DEFAULT_CHART_COLORS,
75
+ DEFAULT_CELESTIAL_POINTS_SETTINGS,
76
+ DEFAULT_CHART_ASPECTS_SETTINGS,
77
+ )
78
+ from typing import List, Literal
79
+
80
+
81
+ class ChartDrawer:
82
+ """
83
+ ChartDrawer generates astrological chart visualizations as SVG files from pre-computed chart data.
84
+
85
+ This class is designed for pure visualization and requires chart data to be pre-computed using
86
+ ChartDataFactory. This separation ensures clean architecture where ChartDataFactory handles
87
+ all calculations (aspects, element/quality distributions, subjects) while ChartDrawer focuses
88
+ solely on rendering SVG visualizations.
89
+
90
+ ChartDrawer supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
91
+ for various chart types including Natal, Transit, Synastry, and Composite.
92
+ Charts are rendered using XML templates and drawing utilities, with customizable themes,
93
+ language, and visual settings.
94
+
95
+ The generated SVG files are optimized for web use and can be saved to any specified
96
+ destination path using the save_svg method.
97
+
98
+ NOTE:
99
+ The generated SVG files are optimized for web use, opening in browsers. If you want to
100
+ use them in other applications, you might need to adjust the SVG settings or styles.
101
+
102
+ Args:
103
+ chart_data (ChartDataModel):
104
+ Pre-computed chart data from ChartDataFactory containing all subjects, aspects,
105
+ element/quality distributions, and other analytical data. This is the ONLY source
106
+ of chart information - no calculations are performed by ChartDrawer.
107
+ theme (KerykeionChartTheme, optional):
108
+ CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'.
109
+ double_chart_aspect_grid_type (Literal['list', 'table'], optional):
110
+ Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
111
+ chart_language (KerykeionChartLanguage, optional):
112
+ Language code for chart labels. Defaults to 'EN'.
113
+ language_pack (dict | None, optional):
114
+ Additional translations merged over the bundled defaults for the
115
+ selected language. Useful to introduce new languages or override
116
+ existing labels.
117
+ transparent_background (bool, optional):
118
+ Whether to use a transparent background instead of the theme color. Defaults to False.
119
+
120
+ Public Methods:
121
+ makeTemplate(minify=False, remove_css_variables=False) -> str:
122
+ Render the full chart SVG as a string without writing to disk. Use `minify=True`
123
+ to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
124
+
125
+ save_svg(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
126
+ Generate and write the full chart SVG file to the specified path.
127
+ If output_path is None, saves to the user's home directory.
128
+ If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart.svg'.
129
+
130
+ makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
131
+ Render only the chart wheel (no aspect grid) as an SVG string.
132
+
133
+ save_wheel_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
134
+ Generate and write the wheel-only SVG file to the specified path.
135
+ If output_path is None, saves to the user's home directory.
136
+ If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
137
+
138
+ makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
139
+ Render only the aspect grid as an SVG string.
140
+
141
+ save_aspect_grid_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
142
+ Generate and write the aspect-grid-only SVG file to the specified path.
143
+ If output_path is None, saves to the user's home directory.
144
+ If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
145
+
146
+ Example:
147
+ >>> from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
148
+ >>> from kerykeion.chart_data_factory import ChartDataFactory
149
+ >>> from kerykeion.charts.chart_drawer import ChartDrawer
150
+ >>>
151
+ >>> # Step 1: Create subject
152
+ >>> subject = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
153
+ >>>
154
+ >>> # Step 2: Pre-compute chart data
155
+ >>> chart_data = ChartDataFactory.create_natal_chart_data(subject)
156
+ >>>
157
+ >>> # Step 3: Create visualization
158
+ >>> chart_drawer = ChartDrawer(chart_data=chart_data, theme="classic")
159
+ >>> chart_drawer.save_svg() # Saves to home directory with default filename
160
+ >>> # Or specify custom path and filename:
161
+ >>> chart_drawer.save_svg("/path/to/output/directory", "my_custom_chart")
162
+ """
163
+
164
+ # Constants
165
+
166
+ _DEFAULT_HEIGHT = 550
167
+ _DEFAULT_FULL_WIDTH = 1250
168
+ _DEFAULT_SYNASTRY_WIDTH = 1570
169
+ _DEFAULT_NATAL_WIDTH = 870
170
+ _DEFAULT_FULL_WIDTH_WITH_TABLE = 1250
171
+ _DEFAULT_ULTRA_WIDE_WIDTH = 1320
172
+
173
+ _ASPECT_LIST_ASPECTS_PER_COLUMN = 14
174
+ _ASPECT_LIST_COLUMN_WIDTH = 105
175
+
176
+ _BASE_VERTICAL_OFFSETS = {
177
+ "wheel": 50,
178
+ "grid": 0,
179
+ "aspect_grid": 50,
180
+ "aspect_list": 50,
181
+ "title": 0,
182
+ "elements": 0,
183
+ "qualities": 0,
184
+ "lunar_phase": 518,
185
+ "bottom_left": 0,
186
+ }
187
+ _MAX_TOP_SHIFT = 80
188
+ _TOP_SHIFT_FACTOR = 2
189
+ _ROW_HEIGHT = 8
190
+
191
+ _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}"
192
+ _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0"
193
+ _ULTRA_WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_ULTRA_WIDE_WIDTH} 546.0"
194
+ _TRANSIT_CHART_WITH_TABLE_VIWBOX = f"0 0 {_DEFAULT_FULL_WIDTH_WITH_TABLE} 546.0"
195
+
196
+ # Set at init
197
+ first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
198
+ second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None]
199
+ chart_type: ChartType
200
+ theme: Union[KerykeionChartTheme, None]
201
+ double_chart_aspect_grid_type: Literal["list", "table"]
202
+ chart_language: KerykeionChartLanguage
203
+ active_points: List[AstrologicalPoint]
204
+ active_aspects: List[ActiveAspect]
205
+ transparent_background: bool
206
+ external_view: bool
207
+ custom_title: Union[str, None]
208
+ _language_model: KerykeionLanguageModel
209
+ _fallback_language_model: KerykeionLanguageModel
210
+
211
+ # Internal properties
212
+ fire: float
213
+ earth: float
214
+ air: float
215
+ water: float
216
+ first_circle_radius: float
217
+ second_circle_radius: float
218
+ third_circle_radius: float
219
+ width: Union[float, int]
220
+ language_settings: dict
221
+ chart_colors_settings: dict
222
+ planets_settings: list[dict[Any, Any]]
223
+ aspects_settings: list[dict[Any, Any]]
224
+ available_planets_setting: List[KerykeionSettingsCelestialPointModel]
225
+ height: float
226
+ location: str
227
+ geolat: float
228
+ geolon: float
229
+ template: str
230
+
231
+ def __init__(
232
+ self,
233
+ chart_data: "ChartDataModel",
234
+ *,
235
+ theme: Union[KerykeionChartTheme, None] = "classic",
236
+ double_chart_aspect_grid_type: Literal["list", "table"] = "list",
237
+ chart_language: KerykeionChartLanguage = "EN",
238
+ language_pack: Optional[Mapping[str, Any]] = None,
239
+ external_view: bool = False,
240
+ transparent_background: bool = False,
241
+ colors_settings: dict = DEFAULT_CHART_COLORS,
242
+ celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
243
+ aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
244
+ custom_title: Union[str, None] = None,
245
+ auto_size: bool = True,
246
+ padding: int = 20,
247
+ ):
248
+ """
249
+ Initialize the chart visualizer with pre-computed chart data.
250
+
251
+ Args:
252
+ chart_data (ChartDataModel):
253
+ Pre-computed chart data from ChartDataFactory containing all subjects,
254
+ aspects, element/quality distributions, and other analytical data.
255
+ theme (KerykeionChartTheme or None, optional):
256
+ CSS theme to apply; None for default styling.
257
+ double_chart_aspect_grid_type (Literal['list','table'], optional):
258
+ Layout style for double-chart aspect grids ('list' or 'table').
259
+ chart_language (KerykeionChartLanguage, optional):
260
+ Language code for chart labels (e.g., 'EN', 'IT').
261
+ language_pack (dict | None, optional):
262
+ Additional translations merged over the bundled defaults for the
263
+ selected language. Useful to introduce new languages or override
264
+ existing labels.
265
+ external_view (bool, optional):
266
+ Whether to use external visualization (planets on outer ring) for single-subject charts. Defaults to False.
267
+ transparent_background (bool, optional):
268
+ Whether to use a transparent background instead of the theme color. Defaults to False.
269
+ custom_title (str or None, optional):
270
+ Custom title for the chart. If None, the default title will be used based on chart type. Defaults to None.
271
+ """
272
+ # --------------------
273
+ # COMMON INITIALIZATION
274
+ # --------------------
275
+ self.chart_language = chart_language
276
+ self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
277
+ self.transparent_background = transparent_background
278
+ self.external_view = external_view
279
+ self.chart_colors_settings = deepcopy(colors_settings)
280
+ self.planets_settings = [dict(body) for body in celestial_points_settings]
281
+ self.aspects_settings = [dict(aspect) for aspect in aspects_settings]
282
+ self.custom_title = custom_title
283
+ self.auto_size = auto_size
284
+ self._padding = padding
285
+ self._vertical_offsets: dict[str, int] = self._BASE_VERTICAL_OFFSETS.copy()
286
+
287
+ # Extract data from ChartDataModel
288
+ self.chart_data = chart_data
289
+ self.chart_type = chart_data.chart_type
290
+ self.active_points = chart_data.active_points
291
+ self.active_aspects = chart_data.active_aspects
292
+
293
+ # Extract subjects based on chart type
294
+ if chart_data.chart_type in ["Natal", "Composite", "SingleReturnChart"]:
295
+ # SingleChartDataModel
296
+ self.first_obj = getattr(chart_data, 'subject')
297
+ self.second_obj = None
298
+
299
+ else: # DualChartDataModel for Transit, Synastry, DualReturnChart
300
+ self.first_obj = getattr(chart_data, 'first_subject')
301
+ self.second_obj = getattr(chart_data, 'second_subject')
302
+
303
+ # Load settings
304
+ self._load_language_settings(language_pack)
305
+
306
+ # Default radius for all charts
307
+ self.main_radius = 240
308
+
309
+ # Configure available planets from chart data
310
+ self.available_planets_setting = []
311
+ for body in self.planets_settings:
312
+ if body["name"] in self.active_points:
313
+ body["is_active"] = True
314
+ self.available_planets_setting.append(body) # type: ignore[arg-type]
315
+
316
+ active_points_count = len(self.available_planets_setting)
317
+ if active_points_count > 24:
318
+ logging.warning(
319
+ "ChartDrawer detected %s active celestial points; rendering may look crowded beyond 24.",
320
+ active_points_count,
321
+ )
322
+
323
+ # Set available celestial points
324
+ available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
325
+ self.available_kerykeion_celestial_points = self._collect_subject_points(
326
+ self.first_obj,
327
+ available_celestial_points_names,
328
+ )
329
+
330
+ # Collect secondary subject points for dual charts using the same active set
331
+ self.t_available_kerykeion_celestial_points: list[KerykeionPointModel] = []
332
+ if self.second_obj is not None:
333
+ self.t_available_kerykeion_celestial_points = self._collect_subject_points(
334
+ self.second_obj,
335
+ available_celestial_points_names,
336
+ )
337
+
338
+ # ------------------------
339
+ # CHART TYPE SPECIFIC SETUP FROM CHART DATA
340
+ # ------------------------
341
+
342
+ if self.chart_type == "Natal":
343
+ # --- NATAL CHART SETUP ---
344
+
345
+ # Extract aspects from pre-computed chart data
346
+ self.aspects_list = chart_data.aspects.relevant_aspects
347
+
348
+ # Screen size
349
+ self.height = self._DEFAULT_HEIGHT
350
+ self.width = self._DEFAULT_NATAL_WIDTH
351
+
352
+ # Get location and coordinates
353
+ self.location, self.geolat, self.geolon = self._get_location_info()
354
+
355
+ # Circle radii - depends on external_view
356
+ if self.external_view:
357
+ self.first_circle_radius = 56
358
+ self.second_circle_radius = 92
359
+ self.third_circle_radius = 112
360
+ else:
361
+ self.first_circle_radius = 0
362
+ self.second_circle_radius = 36
363
+ self.third_circle_radius = 120
364
+
365
+ elif self.chart_type == "Composite":
366
+ # --- COMPOSITE CHART SETUP ---
367
+
368
+ # Extract aspects from pre-computed chart data
369
+ self.aspects_list = chart_data.aspects.relevant_aspects
370
+
371
+ # Screen size
372
+ self.height = self._DEFAULT_HEIGHT
373
+ self.width = self._DEFAULT_NATAL_WIDTH
374
+
375
+ # Get location and coordinates
376
+ self.location, self.geolat, self.geolon = self._get_location_info()
377
+
378
+ # Circle radii
379
+ self.first_circle_radius = 0
380
+ self.second_circle_radius = 36
381
+ self.third_circle_radius = 120
382
+
383
+ elif self.chart_type == "Transit":
384
+ # --- TRANSIT CHART SETUP ---
385
+
386
+ # Extract aspects from pre-computed chart data
387
+ self.aspects_list = chart_data.aspects.relevant_aspects
388
+
389
+ # Screen size
390
+ self.height = self._DEFAULT_HEIGHT
391
+ if self.double_chart_aspect_grid_type == "table":
392
+ self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
393
+ else:
394
+ self.width = self._DEFAULT_FULL_WIDTH
395
+
396
+ # Get location and coordinates
397
+ self.location, self.geolat, self.geolon = self._get_location_info()
398
+
399
+ # Circle radii
400
+ self.first_circle_radius = 0
401
+ self.second_circle_radius = 36
402
+ self.third_circle_radius = 120
403
+
404
+ elif self.chart_type == "Synastry":
405
+ # --- SYNASTRY CHART SETUP ---
406
+
407
+ # Extract aspects from pre-computed chart data
408
+ self.aspects_list = chart_data.aspects.relevant_aspects
409
+
410
+ # Screen size
411
+ self.height = self._DEFAULT_HEIGHT
412
+ self.width = self._DEFAULT_SYNASTRY_WIDTH
413
+
414
+ # Get location and coordinates
415
+ self.location, self.geolat, self.geolon = self._get_location_info()
416
+
417
+ # Circle radii
418
+ self.first_circle_radius = 0
419
+ self.second_circle_radius = 36
420
+ self.third_circle_radius = 120
421
+
422
+ elif self.chart_type == "DualReturnChart":
423
+ # --- RETURN CHART SETUP ---
424
+
425
+ # Extract aspects from pre-computed chart data
426
+ self.aspects_list = chart_data.aspects.relevant_aspects
427
+
428
+ # Screen size
429
+ self.height = self._DEFAULT_HEIGHT
430
+ self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
431
+
432
+ # Get location and coordinates
433
+ self.location, self.geolat, self.geolon = self._get_location_info()
434
+
435
+ # Circle radii
436
+ self.first_circle_radius = 0
437
+ self.second_circle_radius = 36
438
+ self.third_circle_radius = 120
439
+
440
+ elif self.chart_type == "SingleReturnChart":
441
+ # --- SINGLE WHEEL RETURN CHART SETUP ---
442
+
443
+ # Extract aspects from pre-computed chart data
444
+ self.aspects_list = chart_data.aspects.relevant_aspects
445
+
446
+ # Screen size
447
+ self.height = self._DEFAULT_HEIGHT
448
+ self.width = self._DEFAULT_NATAL_WIDTH
449
+
450
+ # Get location and coordinates
451
+ self.location, self.geolat, self.geolon = self._get_location_info()
452
+
453
+ # Circle radii
454
+ self.first_circle_radius = 0
455
+ self.second_circle_radius = 36
456
+ self.third_circle_radius = 120
457
+
458
+ # --------------------
459
+ # FINAL COMMON SETUP FROM CHART DATA
460
+ # --------------------
461
+
462
+ # Extract pre-computed element and quality distributions
463
+ self.fire = chart_data.element_distribution.fire
464
+ self.earth = chart_data.element_distribution.earth
465
+ self.air = chart_data.element_distribution.air
466
+ self.water = chart_data.element_distribution.water
467
+
468
+ self.cardinal = chart_data.quality_distribution.cardinal
469
+ self.fixed = chart_data.quality_distribution.fixed
470
+ self.mutable = chart_data.quality_distribution.mutable
471
+
472
+ # Set up theme
473
+ if theme not in get_args(KerykeionChartTheme) and theme is not None:
474
+ raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
475
+
476
+ self.set_up_theme(theme)
477
+
478
+ self._apply_dynamic_height_adjustment()
479
+ self._adjust_height_for_extended_aspect_columns()
480
+ # Reconcile width with the updated layout once height adjustments are known.
481
+ if self.auto_size:
482
+ self._update_width_to_content()
483
+
484
+ def _count_active_planets(self) -> int:
485
+ """Return number of active celestial points in the current chart."""
486
+ return len([p for p in self.available_planets_setting if p.get("is_active")])
487
+
488
+ def _apply_dynamic_height_adjustment(self) -> None:
489
+ """Adjust chart height and vertical offsets based on active points."""
490
+ active_points_count = self._count_active_planets()
491
+
492
+ offsets = self._BASE_VERTICAL_OFFSETS.copy()
493
+
494
+ minimum_height = self._DEFAULT_HEIGHT
495
+
496
+ if self.chart_type == "Synastry":
497
+ self._apply_synastry_height_adjustment(
498
+ active_points_count=active_points_count,
499
+ offsets=offsets,
500
+ minimum_height=minimum_height,
501
+ )
502
+ return
503
+
504
+ if active_points_count <= 20:
505
+ self.height = max(self.height, minimum_height)
506
+ self._vertical_offsets = offsets
507
+ return
508
+
509
+ extra_points = active_points_count - 20
510
+ extra_height = extra_points * self._ROW_HEIGHT
511
+
512
+ self.height = max(self.height, minimum_height + extra_height)
513
+
514
+ delta_height = max(self.height - minimum_height, 0)
515
+
516
+ # Anchor wheel, aspect grid/list, and lunar phase to the bottom
517
+ offsets["wheel"] += delta_height
518
+ offsets["aspect_grid"] += delta_height
519
+ offsets["aspect_list"] += delta_height
520
+ offsets["lunar_phase"] += delta_height
521
+ offsets["bottom_left"] += delta_height
522
+
523
+ # Smooth top offsets to keep breathing room near the title and grids
524
+ shift = min(extra_points * self._TOP_SHIFT_FACTOR, self._MAX_TOP_SHIFT)
525
+ top_shift = shift // 2
526
+
527
+ offsets["grid"] += shift
528
+ offsets["title"] += top_shift
529
+ offsets["elements"] += top_shift
530
+ offsets["qualities"] += top_shift
531
+
532
+ self._vertical_offsets = offsets
533
+
534
+ def _adjust_height_for_extended_aspect_columns(self) -> None:
535
+ """Ensure tall aspect columns fit within the SVG for double-chart lists."""
536
+ if self.double_chart_aspect_grid_type != "list":
537
+ return
538
+
539
+ if self.chart_type not in ("Synastry", "Transit", "DualReturnChart"):
540
+ return
541
+
542
+ total_aspects = len(self.aspects_list) if hasattr(self, "aspects_list") else 0
543
+ if total_aspects == 0:
544
+ return
545
+
546
+ aspects_per_column = 14
547
+ extended_column_start = 11 # Zero-based column index where tall columns begin
548
+ base_capacity = aspects_per_column * extended_column_start
549
+
550
+ if total_aspects <= base_capacity:
551
+ return
552
+
553
+ translate_y = 273
554
+ bottom_padding = 40
555
+ title_clearance = 18
556
+ line_height = 14
557
+ baseline_index = aspects_per_column - 1
558
+ top_limit_index = ceil((-translate_y + title_clearance) / line_height)
559
+ max_capacity_by_top = baseline_index - top_limit_index + 1
560
+
561
+ if max_capacity_by_top <= aspects_per_column:
562
+ return
563
+
564
+ target_capacity = max_capacity_by_top
565
+ required_available_height = target_capacity * line_height
566
+ required_height = translate_y + bottom_padding + required_available_height
567
+
568
+ if required_height <= self.height:
569
+ return
570
+
571
+ delta = required_height - self.height
572
+ self.height = required_height
573
+
574
+ offsets = self._vertical_offsets
575
+ # Keep bottom-anchored groups aligned after changing the overall height.
576
+ offsets["wheel"] += delta
577
+ offsets["aspect_grid"] += delta
578
+ offsets["aspect_list"] += delta
579
+ offsets["lunar_phase"] += delta
580
+ offsets["bottom_left"] += delta
581
+ self._vertical_offsets = offsets
582
+
583
+ def _apply_synastry_height_adjustment(
584
+ self,
585
+ *,
586
+ active_points_count: int,
587
+ offsets: dict[str, int],
588
+ minimum_height: int,
589
+ ) -> None:
590
+ """Specialised dynamic height handling for Synastry charts.
591
+
592
+ With the planet grids locked to a single column, every additional active
593
+ point extends multiple tables vertically (planets, houses, comparisons).
594
+ We therefore scale the height using the actual line spacing used by those
595
+ tables (≈14px) and keep the bottom anchored elements aligned.
596
+ """
597
+ base_rows = 14 # Up to 16 active points fit without extra height
598
+ extra_rows = max(active_points_count - base_rows, 0)
599
+
600
+ synastry_row_height = 15
601
+ comparison_padding_per_row = 4 # Keeps house comparison grids within view.
602
+ extra_height = extra_rows * (synastry_row_height + comparison_padding_per_row)
603
+
604
+ self.height = max(self.height, minimum_height + extra_height)
605
+
606
+ delta_height = max(self.height - minimum_height, 0)
607
+
608
+ offsets["wheel"] += delta_height
609
+ offsets["aspect_grid"] += delta_height
610
+ offsets["aspect_list"] += delta_height
611
+ offsets["lunar_phase"] += delta_height
612
+ offsets["bottom_left"] += delta_height
613
+
614
+ row_height_ratio = synastry_row_height / max(self._ROW_HEIGHT, 1)
615
+ synastry_top_shift_factor = max(
616
+ self._TOP_SHIFT_FACTOR,
617
+ int(ceil(self._TOP_SHIFT_FACTOR * row_height_ratio)),
618
+ )
619
+ shift = min(extra_rows * synastry_top_shift_factor, self._MAX_TOP_SHIFT)
620
+
621
+ base_grid_padding = 36
622
+ grid_padding_per_row = 6
623
+ base_header_padding = 12
624
+ header_padding_per_row = 4
625
+ min_title_to_grid_gap = 36
626
+
627
+ grid_shift = shift + base_grid_padding + (extra_rows * grid_padding_per_row)
628
+ grid_shift = min(grid_shift, shift + self._MAX_TOP_SHIFT)
629
+
630
+ top_shift = (shift // 2) + base_header_padding + (extra_rows * header_padding_per_row)
631
+
632
+ max_allowed_shift = shift + self._MAX_TOP_SHIFT
633
+ missing_gap = min_title_to_grid_gap - (grid_shift - top_shift)
634
+ grid_shift = min(grid_shift + missing_gap, max_allowed_shift)
635
+ if grid_shift - top_shift < min_title_to_grid_gap:
636
+ top_shift = max(0, grid_shift - min_title_to_grid_gap)
637
+
638
+ offsets["grid"] += grid_shift
639
+ offsets["title"] += top_shift
640
+ offsets["elements"] += top_shift
641
+ offsets["qualities"] += top_shift
642
+
643
+ self._vertical_offsets = offsets
644
+
645
+ def _collect_subject_points(
646
+ self,
647
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
648
+ point_attribute_names: list[str],
649
+ ) -> list[KerykeionPointModel]:
650
+ """Collect ordered active celestial points for a subject."""
651
+
652
+ collected: list[KerykeionPointModel] = []
653
+
654
+ for raw_name in point_attribute_names:
655
+ attr_name = raw_name if hasattr(subject, raw_name) else raw_name.lower()
656
+ point = getattr(subject, attr_name, None)
657
+ if point is None:
658
+ continue
659
+ collected.append(point)
660
+
661
+ return collected
662
+
663
+ def _dynamic_viewbox(self) -> str:
664
+ """Return the viewBox string based on current width/height."""
665
+ return f"0 0 {int(self.width)} {int(self.height)}"
666
+
667
+ def _wheel_only_viewbox(self, margin: int = 20) -> str:
668
+ """Return a tight viewBox for the wheel-only template.
669
+
670
+ The wheel is drawn inside a group translated by (100, 50) and has
671
+ diameter 2 * main_radius. We add a small margin around it.
672
+ """
673
+ left = 100 - margin
674
+ top = 50 - margin
675
+ width = (2 * self.main_radius) + (2 * margin)
676
+ height = (2 * self.main_radius) + (2 * margin)
677
+ return f"{left} {top} {width} {height}"
678
+
679
+ def _grid_only_viewbox(self, margin: int = 10) -> str:
680
+ """Compute a tight viewBox for the Aspect Grid Only SVG.
681
+
682
+ The grid is rendered using fixed origins and box size:
683
+ - For Transit/Synastry/DualReturn charts, `draw_transit_aspect_grid`
684
+ uses `x_indent=50`, `y_indent=250`, `box_size=14` and draws:
685
+ • a header row to the right of `x_indent`
686
+ • a left header column at `x_indent - box_size`
687
+ • an NxN grid of cells above `y_indent`
688
+
689
+ - For Natal/Composite/SingleReturn charts, `draw_aspect_grid` uses
690
+ `x_start=50`, `y_start=250`, `box_size=14` and draws a triangular grid
691
+ that extends to the right (x) and upwards (y).
692
+
693
+ This function mirrors that geometry to return a snug viewBox around the
694
+ content, with a small configurable `margin`.
695
+
696
+ Args:
697
+ margin: Extra pixels to add on each side of the computed bounds.
698
+
699
+ Returns:
700
+ A string "minX minY width height" suitable for the SVG `viewBox`.
701
+ """
702
+ # Must match defaults used in the renderers
703
+ x0 = 50
704
+ y0 = 250
705
+ box = 14
706
+
707
+ n = max(len([p for p in self.available_planets_setting if p.get("is_active")]), 1)
708
+
709
+ if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
710
+ # Full N×N grid
711
+ left = (x0 - box) - margin
712
+ top = (y0 - box * n) - margin
713
+ right = (x0 + box * n) + margin
714
+ bottom = (y0 + box) + margin
715
+ else:
716
+ # Triangular grid (no extra left column)
717
+ left = x0 - margin
718
+ top = (y0 - box * n) - margin
719
+ right = (x0 + box * n) + margin
720
+ bottom = (y0 + box) + margin
721
+
722
+ width = max(1, int(right - left))
723
+ height = max(1, int(bottom - top))
724
+
725
+ return f"{int(left)} {int(top)} {width} {height}"
726
+
727
+ def _estimate_required_width_full(self) -> int:
728
+ """Estimate minimal width to contain all rendered groups for the full chart.
729
+
730
+ The calculation is heuristic and mirrors the default x positions used in
731
+ the SVG templates and drawing utilities. We keep a conservative padding.
732
+ """
733
+ # Wheel footprint (translate(100,50) + diameter of 2*radius)
734
+ wheel_right = 100 + (2 * self.main_radius)
735
+ extents = [wheel_right]
736
+
737
+ n_active = max(self._count_active_planets(), 1)
738
+
739
+ # Common grids present on many chart types
740
+ main_planet_grid_right = 645 + 80
741
+ main_houses_grid_right = 750 + 120
742
+ extents.extend([main_planet_grid_right, main_houses_grid_right])
743
+
744
+ if self.chart_type in ("Natal", "Composite", "SingleReturnChart"):
745
+ # Triangular aspect grid at x_start=540, width ~ 14 * n_active
746
+ aspect_grid_right = 560 + 14 * n_active
747
+ extents.append(aspect_grid_right)
748
+
749
+ if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
750
+ # Double-chart aspects placement
751
+ if self.double_chart_aspect_grid_type == "list":
752
+ total_aspects = len(self.aspects_list) if hasattr(self, "aspects_list") else 0
753
+ columns = self._calculate_double_chart_aspect_columns(total_aspects, self.height)
754
+ columns = max(columns, 1)
755
+ aspect_list_right = 565 + (columns * self._ASPECT_LIST_COLUMN_WIDTH)
756
+ extents.append(aspect_list_right)
757
+ else:
758
+ # Grid table placed with x_indent ~550, width ~ 14px per cell across n_active+1
759
+ aspect_grid_table_right = 550 + (14 * (n_active + 1))
760
+ extents.append(aspect_grid_table_right)
761
+
762
+ # Secondary grids
763
+ secondary_planet_grid_right = 910 + 80
764
+ extents.append(secondary_planet_grid_right)
765
+
766
+ if self.chart_type == "Synastry":
767
+ # Secondary houses grid default x ~ 1015
768
+ secondary_houses_grid_right = 1015 + 120
769
+ extents.append(secondary_houses_grid_right)
770
+ first_house_comparison_grid_right = 1090 + 180
771
+ second_house_comparison_grid_right = 1290 + 180
772
+ extents.extend([first_house_comparison_grid_right, second_house_comparison_grid_right])
773
+
774
+ if self.chart_type == "Transit":
775
+ # House comparison grid at x ~ 1030
776
+ house_comparison_grid_right = 1030 + 180
777
+ extents.append(house_comparison_grid_right)
778
+
779
+ if self.chart_type == "DualReturnChart":
780
+ # House comparison grid at x ~ 1030
781
+ house_comparison_grid_right = 1030 + 320
782
+ extents.append(house_comparison_grid_right)
783
+
784
+ # Conservative safety padding
785
+ return int(max(extents) + self._padding)
786
+
787
+ def _calculate_double_chart_aspect_columns(
788
+ self,
789
+ total_aspects: int,
790
+ chart_height: Optional[int],
791
+ ) -> int:
792
+ """Return how many columns the double-chart aspect list needs.
793
+
794
+ The first 11 columns follow the legacy 14-rows layout. Starting from the
795
+ 12th column we can fit more rows thanks to the taller chart height that
796
+ gets computed earlier, so we re-use the same capacity as the SVG builder.
797
+ """
798
+ if total_aspects <= 0:
799
+ return 0
800
+
801
+ per_column = self._ASPECT_LIST_ASPECTS_PER_COLUMN
802
+ extended_start = 10 # 0-based index where tall columns begin
803
+ base_capacity = per_column * extended_start
804
+
805
+ full_height_capacity = self._calculate_full_height_column_capacity(chart_height)
806
+
807
+ if total_aspects <= base_capacity:
808
+ return ceil(total_aspects / per_column)
809
+
810
+ remaining = max(total_aspects - base_capacity, 0)
811
+ extra_columns = ceil(remaining / full_height_capacity) if remaining > 0 else 0
812
+ return extended_start + extra_columns
813
+
814
+ def _calculate_full_height_column_capacity(
815
+ self,
816
+ chart_height: Optional[int],
817
+ ) -> int:
818
+ """Compute the row capacity for columns that use the tall layout."""
819
+ per_column = self._ASPECT_LIST_ASPECTS_PER_COLUMN
820
+
821
+ if chart_height is None:
822
+ return per_column
823
+
824
+ translate_y = 273
825
+ bottom_padding = 40
826
+ title_clearance = 18
827
+ line_height = 14
828
+ baseline_index = per_column - 1
829
+ top_limit_index = ceil((-translate_y + title_clearance) / line_height)
830
+ max_capacity_by_top = baseline_index - top_limit_index + 1
831
+
832
+ available_height = max(chart_height - translate_y - bottom_padding, line_height)
833
+ allowed_capacity = max(per_column, int(available_height // line_height))
834
+
835
+ # Respect both the physical height of the SVG and the visual limit
836
+ # imposed by the title area.
837
+ return max(per_column, min(allowed_capacity, max_capacity_by_top))
838
+
839
+ def _minimum_width_for_chart_type(self) -> int:
840
+ """Baseline width to avoid compressing core groups too tightly."""
841
+ wheel_right = 100 + (2 * self.main_radius)
842
+ baseline = wheel_right + self._padding
843
+
844
+ if self.chart_type in ("Natal", "Composite", "SingleReturnChart"):
845
+ return max(int(baseline), self._DEFAULT_NATAL_WIDTH)
846
+ if self.chart_type == "Synastry":
847
+ return max(int(baseline), self._DEFAULT_SYNASTRY_WIDTH // 2)
848
+ if self.chart_type == "DualReturnChart":
849
+ return max(int(baseline), self._DEFAULT_ULTRA_WIDE_WIDTH // 2)
850
+ if self.chart_type == "Transit":
851
+ return max(int(baseline), self._DEFAULT_FULL_WIDTH // 2)
852
+ return max(int(baseline), self._DEFAULT_NATAL_WIDTH)
853
+
854
+ def _update_width_to_content(self) -> None:
855
+ """Resize the chart width so the farthest element fits comfortably."""
856
+ try:
857
+ required_width = self._estimate_required_width_full()
858
+ except Exception as e:
859
+ logging.debug(f"Auto-size width calculation failed: {e}")
860
+ return
861
+
862
+ minimum_width = self._minimum_width_for_chart_type()
863
+ self.width = max(required_width, minimum_width)
864
+
865
+ def _get_location_info(self) -> tuple[str, float, float]:
866
+ """
867
+ Determine location information based on chart type and subjects.
868
+
869
+ Returns:
870
+ tuple: (location_name, latitude, longitude)
871
+ """
872
+ if self.chart_type == "Composite":
873
+ # For composite charts, use average location of the two composite subjects
874
+ if isinstance(self.first_obj, CompositeSubjectModel):
875
+ location_name = ""
876
+ latitude = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
877
+ longitude = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
878
+ else:
879
+ # Fallback to first subject location
880
+ location_name = self.first_obj.city or "Unknown"
881
+ latitude = self.first_obj.lat or 0.0
882
+ longitude = self.first_obj.lng or 0.0
883
+ elif self.chart_type in ["Transit", "DualReturnChart"] and self.second_obj:
884
+ # Use location from the second subject (transit/return)
885
+ location_name = self.second_obj.city or "Unknown"
886
+ latitude = self.second_obj.lat or 0.0
887
+ longitude = self.second_obj.lng or 0.0
888
+ else:
889
+ # Use location from the first subject
890
+ location_name = self.first_obj.city or "Unknown"
891
+ latitude = self.first_obj.lat or 0.0
892
+ longitude = self.first_obj.lng or 0.0
893
+
894
+ return location_name, latitude, longitude
895
+
896
+ def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
897
+ """
898
+ Load and apply a CSS theme for the chart visualization.
899
+
900
+ Args:
901
+ theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
902
+ """
903
+ if theme is None:
904
+ self.color_style_tag = ""
905
+ return
906
+
907
+ theme_dir = Path(__file__).parent / "themes"
908
+
909
+ with open(theme_dir / f"{theme}.css", "r") as f:
910
+ self.color_style_tag = f.read()
911
+
912
+ def _load_language_settings(
913
+ self,
914
+ language_pack: Optional[Mapping[str, Any]],
915
+ ) -> None:
916
+ """Resolve language models for the requested chart language."""
917
+ overrides = {self.chart_language: dict(language_pack)} if language_pack else None
918
+ languages = load_language_settings(overrides)
919
+
920
+ fallback_data = languages.get("EN")
921
+ if fallback_data is None:
922
+ raise KerykeionException("English translations are missing from LANGUAGE_SETTINGS.")
923
+
924
+ base_data = languages.get(self.chart_language, fallback_data)
925
+ selected_model = KerykeionLanguageModel(**base_data)
926
+ fallback_model = KerykeionLanguageModel(**fallback_data)
927
+
928
+ self._fallback_language_model = fallback_model
929
+ self._language_model = selected_model
930
+ self._fallback_language_dict = fallback_model.model_dump()
931
+ self._language_dict = selected_model.model_dump()
932
+ self.language_settings = self._language_dict # Backward compatibility
933
+
934
+ def _translate(self, key: str, default: Any) -> Any:
935
+ fallback_value = get_translations(key, default, language_dict=self._fallback_language_dict)
936
+ return get_translations(key, fallback_value, language_dict=self._language_dict)
937
+
938
+ def _draw_zodiac_circle_slices(self, r):
939
+ """
940
+ Draw zodiac circle slices for each sign.
941
+
942
+ Args:
943
+ r (float): Outer radius of the zodiac ring.
944
+
945
+ Returns:
946
+ str: Concatenated SVG elements for zodiac slices.
947
+ """
948
+ sings = get_args(Sign)
949
+ output = ""
950
+ for i, sing in enumerate(sings):
951
+ output += draw_zodiac_slice(
952
+ c1=self.first_circle_radius,
953
+ chart_type=self.chart_type,
954
+ seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
955
+ num=i,
956
+ r=r,
957
+ style=f'fill:{self.chart_colors_settings[f"zodiac_bg_{i}"]}; fill-opacity: 0.5;',
958
+ type=sing,
959
+ )
960
+
961
+ return output
962
+
963
+ def _draw_all_aspects_lines(self, r, ar):
964
+ """
965
+ Render SVG lines for all aspects in the chart.
966
+
967
+ Args:
968
+ r (float): Radius at which aspect lines originate.
969
+ ar (float): Radius at which aspect lines terminate.
970
+
971
+ Returns:
972
+ str: SVG markup for all aspect lines.
973
+ """
974
+ out = ""
975
+ for aspect in self.aspects_list:
976
+ aspect_name = aspect["aspect"]
977
+ aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
978
+ if aspect_color:
979
+ out += draw_aspect_line(
980
+ r=r,
981
+ ar=ar,
982
+ aspect=aspect,
983
+ color=aspect_color,
984
+ seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
985
+ )
986
+ return out
987
+
988
+ def _draw_all_transit_aspects_lines(self, r, ar):
989
+ """
990
+ Render SVG lines for all transit aspects in the chart.
991
+
992
+ Args:
993
+ r (float): Radius at which transit aspect lines originate.
994
+ ar (float): Radius at which transit aspect lines terminate.
995
+
996
+ Returns:
997
+ str: SVG markup for all transit aspect lines.
998
+ """
999
+ out = ""
1000
+ for aspect in self.aspects_list:
1001
+ aspect_name = aspect["aspect"]
1002
+ aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
1003
+ if aspect_color:
1004
+ out += draw_aspect_line(
1005
+ r=r,
1006
+ ar=ar,
1007
+ aspect=aspect,
1008
+ color=aspect_color,
1009
+ seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1010
+ )
1011
+ return out
1012
+
1013
+ def _truncate_name(self, name: str, max_length: int = 50, ellipsis_symbol: str = "…", truncate_at_space: bool = False) -> str:
1014
+ """
1015
+ Truncate a name if it's too long, preserving readability.
1016
+
1017
+ Args:
1018
+ name (str): The name to truncate
1019
+ max_length (int): Maximum allowed length
1020
+
1021
+ Returns:
1022
+ str: Truncated name with ellipsis if needed
1023
+ """
1024
+ if truncate_at_space:
1025
+ name = name.split(" ")[0]
1026
+
1027
+ if len(name) <= max_length:
1028
+ return name
1029
+
1030
+ return name[:max_length-1] + ellipsis_symbol
1031
+
1032
+ def _get_chart_title(self) -> str:
1033
+ """
1034
+ Generate the chart title based on chart type and custom title settings.
1035
+
1036
+ If a custom title is provided, it will be used. Otherwise, generates the
1037
+ appropriate default title based on the chart type and subjects.
1038
+
1039
+ Returns:
1040
+ str: The chart title to display (max ~40 characters).
1041
+ """
1042
+ # If custom title is provided, use it
1043
+ if self.custom_title is not None:
1044
+ return self.custom_title
1045
+
1046
+ # Generate default title based on chart type
1047
+ if self.chart_type == "Natal":
1048
+ natal_label = self._translate("birth_chart", "Natal")
1049
+ truncated_name = self._truncate_name(self.first_obj.name)
1050
+ return f'{truncated_name} - {natal_label}'
1051
+
1052
+ elif self.chart_type == "Composite":
1053
+ composite_label = self._translate("composite_chart", "Composite")
1054
+ and_word = self._translate("and_word", "&")
1055
+ name1 = self._truncate_name(self.first_obj.first_subject.name) # type: ignore
1056
+ name2 = self._truncate_name(self.first_obj.second_subject.name) # type: ignore
1057
+ return f"{composite_label}: {name1} {and_word} {name2}"
1058
+
1059
+ elif self.chart_type == "Transit":
1060
+ transit_label = self._translate("transits", "Transits")
1061
+ date_obj = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime) # type: ignore
1062
+ date_str = date_obj.strftime("%d/%m/%y")
1063
+ truncated_name = self._truncate_name(self.first_obj.name)
1064
+ return f"{truncated_name} - {transit_label} {date_str}"
1065
+
1066
+ elif self.chart_type == "Synastry":
1067
+ synastry_label = self._translate("synastry_chart", "Synastry")
1068
+ and_word = self._translate("and_word", "&")
1069
+ name1 = self._truncate_name(self.first_obj.name)
1070
+ name2 = self._truncate_name(self.second_obj.name) # type: ignore
1071
+ return f"{synastry_label}: {name1} {and_word} {name2}"
1072
+
1073
+ elif self.chart_type == "DualReturnChart":
1074
+ year = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime).year # type: ignore
1075
+ truncated_name = self._truncate_name(self.first_obj.name)
1076
+ if self.second_obj is not None and isinstance(self.second_obj, PlanetReturnModel) and self.second_obj.return_type == "Solar":
1077
+ solar_label = self._translate("solar_return", "Solar")
1078
+ return f"{truncated_name} - {solar_label} {year}"
1079
+ else:
1080
+ lunar_label = self._translate("lunar_return", "Lunar")
1081
+ return f"{truncated_name} - {lunar_label} {year}"
1082
+
1083
+ elif self.chart_type == "SingleReturnChart":
1084
+ year = datetime.fromisoformat(self.first_obj.iso_formatted_local_datetime).year # type: ignore
1085
+ truncated_name = self._truncate_name(self.first_obj.name)
1086
+ if isinstance(self.first_obj, PlanetReturnModel) and self.first_obj.return_type == "Solar":
1087
+ solar_label = self._translate("solar_return", "Solar")
1088
+ return f"{truncated_name} - {solar_label} {year}"
1089
+ else:
1090
+ lunar_label = self._translate("lunar_return", "Lunar")
1091
+ return f"{truncated_name} - {lunar_label} {year}"
1092
+
1093
+ # Fallback for unknown chart types
1094
+ return self._truncate_name(self.first_obj.name)
1095
+
1096
+ def _create_template_dictionary(self) -> ChartTemplateModel:
1097
+ """
1098
+ Assemble chart data and rendering instructions into a template dictionary.
1099
+
1100
+ Gathers styling, dimensions, and SVG fragments for chart components based on
1101
+ chart type and subjects.
1102
+
1103
+ Returns:
1104
+ ChartTemplateModel: Populated structure of template variables.
1105
+ """
1106
+ # Initialize template dictionary
1107
+ template_dict: dict = {}
1108
+
1109
+ # -------------------------------------#
1110
+ # COMMON SETTINGS FOR ALL CHART TYPES #
1111
+ # -------------------------------------#
1112
+
1113
+ # Set the color style tag and basic dimensions
1114
+ template_dict["color_style_tag"] = self.color_style_tag
1115
+ template_dict["chart_height"] = self.height
1116
+ template_dict["chart_width"] = self.width
1117
+
1118
+ offsets = self._vertical_offsets
1119
+ template_dict["full_wheel_translate_y"] = offsets["wheel"]
1120
+ template_dict["houses_and_planets_translate_y"] = offsets["grid"]
1121
+ template_dict["aspect_grid_translate_y"] = offsets["aspect_grid"]
1122
+ template_dict["aspect_list_translate_y"] = offsets["aspect_list"]
1123
+ template_dict["title_translate_y"] = offsets["title"]
1124
+ template_dict["elements_translate_y"] = offsets["elements"]
1125
+ template_dict["qualities_translate_y"] = offsets["qualities"]
1126
+ template_dict["lunar_phase_translate_y"] = offsets["lunar_phase"]
1127
+ template_dict["bottom_left_translate_y"] = offsets["bottom_left"]
1128
+
1129
+ # Set paper colors
1130
+ template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
1131
+
1132
+ # Set background color based on transparent_background setting
1133
+ if self.transparent_background:
1134
+ template_dict["background_color"] = "transparent"
1135
+ else:
1136
+ template_dict["background_color"] = self.chart_colors_settings["paper_1"]
1137
+
1138
+ # Set planet colors - initialize all possible colors first with defaults
1139
+ default_color = "#000000" # Default black color for unused planets
1140
+ for i in range(42): # Support all 42 celestial points (0-41)
1141
+ template_dict[f"planets_color_{i}"] = default_color
1142
+
1143
+ # Override with actual colors from settings
1144
+ for planet in self.planets_settings:
1145
+ planet_id = planet["id"]
1146
+ template_dict[f"planets_color_{planet_id}"] = planet["color"]
1147
+
1148
+ # Set zodiac colors
1149
+ for i in range(12):
1150
+ template_dict[f"zodiac_color_{i}"] = self.chart_colors_settings[f"zodiac_icon_{i}"]
1151
+
1152
+ # Set orb colors
1153
+ for aspect in self.aspects_settings:
1154
+ template_dict[f"orb_color_{aspect['degree']}"] = aspect["color"]
1155
+
1156
+ # Draw zodiac circle slices
1157
+ template_dict["makeZodiac"] = self._draw_zodiac_circle_slices(self.main_radius)
1158
+
1159
+ # Calculate element percentages
1160
+ total_elements = self.fire + self.water + self.earth + self.air
1161
+ element_values = {"fire": self.fire, "earth": self.earth, "air": self.air, "water": self.water}
1162
+ element_percentages = distribute_percentages_to_100(element_values) if total_elements > 0 else {"fire": 0, "earth": 0, "air": 0, "water": 0}
1163
+ fire_percentage = element_percentages["fire"]
1164
+ earth_percentage = element_percentages["earth"]
1165
+ air_percentage = element_percentages["air"]
1166
+ water_percentage = element_percentages["water"]
1167
+
1168
+ # Element Percentages
1169
+ template_dict["elements_string"] = f"{self._translate('elements', 'Elements')}:"
1170
+ template_dict["fire_string"] = f"{self._translate('fire', 'Fire')} {fire_percentage}%"
1171
+ template_dict["earth_string"] = f"{self._translate('earth', 'Earth')} {earth_percentage}%"
1172
+ template_dict["air_string"] = f"{self._translate('air', 'Air')} {air_percentage}%"
1173
+ template_dict["water_string"] = f"{self._translate('water', 'Water')} {water_percentage}%"
1174
+
1175
+
1176
+ # Qualities Percentages
1177
+ total_qualities = self.cardinal + self.fixed + self.mutable
1178
+ quality_values = {"cardinal": self.cardinal, "fixed": self.fixed, "mutable": self.mutable}
1179
+ quality_percentages = distribute_percentages_to_100(quality_values) if total_qualities > 0 else {"cardinal": 0, "fixed": 0, "mutable": 0}
1180
+ cardinal_percentage = quality_percentages["cardinal"]
1181
+ fixed_percentage = quality_percentages["fixed"]
1182
+ mutable_percentage = quality_percentages["mutable"]
1183
+
1184
+ template_dict["qualities_string"] = f"{self._translate('qualities', 'Qualities')}:"
1185
+ template_dict["cardinal_string"] = f"{self._translate('cardinal', 'Cardinal')} {cardinal_percentage}%"
1186
+ template_dict["fixed_string"] = f"{self._translate('fixed', 'Fixed')} {fixed_percentage}%"
1187
+ template_dict["mutable_string"] = f"{self._translate('mutable', 'Mutable')} {mutable_percentage}%"
1188
+
1189
+ # Get houses list for main subject
1190
+ first_subject_houses_list = get_houses_list(self.first_obj)
1191
+
1192
+ # Chart title
1193
+ template_dict["stringTitle"] = self._get_chart_title()
1194
+
1195
+ # ------------------------------- #
1196
+ # CHART TYPE SPECIFIC SETTINGS #
1197
+ # ------------------------------- #
1198
+
1199
+ if self.chart_type == "Natal":
1200
+ # Set viewbox dynamically
1201
+ template_dict["viewbox"] = self._dynamic_viewbox()
1202
+
1203
+ # Rings and circles
1204
+ template_dict["transitRing"] = ""
1205
+ template_dict["degreeRing"] = draw_degree_ring(
1206
+ self.main_radius,
1207
+ self.first_circle_radius,
1208
+ self.first_obj.seventh_house.abs_pos,
1209
+ self.chart_colors_settings["paper_0"],
1210
+ )
1211
+ template_dict["background_circle"] = draw_background_circle(
1212
+ self.main_radius,
1213
+ self.chart_colors_settings["paper_1"],
1214
+ self.chart_colors_settings["paper_1"],
1215
+ )
1216
+ template_dict["first_circle"] = draw_first_circle(
1217
+ self.main_radius,
1218
+ self.chart_colors_settings["zodiac_radix_ring_2"],
1219
+ self.chart_type,
1220
+ self.first_circle_radius,
1221
+ )
1222
+ template_dict["second_circle"] = draw_second_circle(
1223
+ self.main_radius,
1224
+ self.chart_colors_settings["zodiac_radix_ring_1"],
1225
+ self.chart_colors_settings["paper_1"],
1226
+ self.chart_type,
1227
+ self.second_circle_radius,
1228
+ )
1229
+ template_dict["third_circle"] = draw_third_circle(
1230
+ self.main_radius,
1231
+ self.chart_colors_settings["zodiac_radix_ring_0"],
1232
+ self.chart_colors_settings["paper_1"],
1233
+ self.chart_type,
1234
+ self.third_circle_radius,
1235
+ )
1236
+
1237
+ # Aspects
1238
+ template_dict["makeDoubleChartAspectList"] = ""
1239
+ template_dict["makeAspectGrid"] = draw_aspect_grid(
1240
+ self.chart_colors_settings["paper_0"],
1241
+ self.available_planets_setting,
1242
+ self.aspects_list,
1243
+ )
1244
+ template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1245
+
1246
+ # Top left section
1247
+ latitude_string = convert_latitude_coordinate_to_string(
1248
+ self.geolat,
1249
+ self._translate("north", "North"),
1250
+ self._translate("south", "South"),
1251
+ )
1252
+ longitude_string = convert_longitude_coordinate_to_string(
1253
+ self.geolon,
1254
+ self._translate("east", "East"),
1255
+ self._translate("west", "West"),
1256
+ )
1257
+
1258
+ template_dict["top_left_0"] = f'{self._translate("location", "Location")}:'
1259
+ template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}"
1260
+ template_dict["top_left_2"] = f"{self._translate('latitude', 'Latitude')}: {latitude_string}"
1261
+ template_dict["top_left_3"] = f"{self._translate('longitude', 'Longitude')}: {longitude_string}"
1262
+ template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1263
+ localized_weekday = self._translate(
1264
+ f"weekdays.{self.first_obj.day_of_week}",
1265
+ self.first_obj.day_of_week, # type: ignore[arg-type]
1266
+ )
1267
+ template_dict["top_left_5"] = f"{self._translate('day_of_week', 'Day of Week')}: {localized_weekday}" # type: ignore
1268
+
1269
+ # Bottom left section
1270
+ if self.first_obj.zodiac_type == "Tropic":
1271
+ zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1272
+ else:
1273
+ mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1274
+ mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1275
+ zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1276
+
1277
+ template_dict["bottom_left_0"] = zodiac_info
1278
+ template_dict["bottom_left_1"] = (
1279
+ f"{self._translate('domification', 'Domification')}: "
1280
+ f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1281
+ )
1282
+
1283
+ # Lunar phase information (optional)
1284
+ if self.first_obj.lunar_phase is not None:
1285
+ template_dict["bottom_left_2"] = (
1286
+ f'{self._translate("lunation_day", "Lunation Day")}: '
1287
+ f'{self.first_obj.lunar_phase.get("moon_phase", "")}'
1288
+ )
1289
+ template_dict["bottom_left_3"] = (
1290
+ f'{self._translate("lunar_phase", "Lunar Phase")}: '
1291
+ f'{self._translate(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
1292
+ )
1293
+ else:
1294
+ template_dict["bottom_left_2"] = ""
1295
+ template_dict["bottom_left_3"] = ""
1296
+
1297
+ template_dict["bottom_left_4"] = (
1298
+ f'{self._translate("perspective_type", "Perspective")}: '
1299
+ f'{self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1300
+ )
1301
+
1302
+ # Moon phase section calculations
1303
+ if self.first_obj.lunar_phase is not None:
1304
+ template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1305
+ else:
1306
+ template_dict["makeLunarPhase"] = ""
1307
+
1308
+ # Houses and planet drawing
1309
+ template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1310
+ main_subject_houses_list=first_subject_houses_list,
1311
+ text_color=self.chart_colors_settings["paper_0"],
1312
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1313
+ )
1314
+ template_dict["makeSecondaryHousesGrid"] = ""
1315
+
1316
+ template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1317
+ r=self.main_radius,
1318
+ first_subject_houses_list=first_subject_houses_list,
1319
+ standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1320
+ first_house_color=self.planets_settings[12]["color"],
1321
+ tenth_house_color=self.planets_settings[13]["color"],
1322
+ seventh_house_color=self.planets_settings[14]["color"],
1323
+ fourth_house_color=self.planets_settings[15]["color"],
1324
+ c1=self.first_circle_radius,
1325
+ c3=self.third_circle_radius,
1326
+ chart_type=self.chart_type,
1327
+ external_view=self.external_view,
1328
+ )
1329
+
1330
+ template_dict["makePlanets"] = draw_planets(
1331
+ available_planets_setting=self.available_planets_setting,
1332
+ chart_type=self.chart_type,
1333
+ radius=self.main_radius,
1334
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1335
+ third_circle_radius=self.third_circle_radius,
1336
+ main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1337
+ main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1338
+ external_view=self.external_view,
1339
+ )
1340
+
1341
+ template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1342
+ planets_and_houses_grid_title=self._translate("planets_and_house", "Points for"),
1343
+ subject_name=self.first_obj.name,
1344
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1345
+ chart_type=self.chart_type,
1346
+ text_color=self.chart_colors_settings["paper_0"],
1347
+ celestial_point_language=self._language_model.celestial_points,
1348
+ )
1349
+ template_dict["makeSecondaryPlanetGrid"] = ""
1350
+ template_dict["makeHouseComparisonGrid"] = ""
1351
+
1352
+ elif self.chart_type == "Composite":
1353
+ # Set viewbox dynamically
1354
+ template_dict["viewbox"] = self._dynamic_viewbox()
1355
+
1356
+ # Rings and circles
1357
+ template_dict["transitRing"] = ""
1358
+ template_dict["degreeRing"] = draw_degree_ring(
1359
+ self.main_radius,
1360
+ self.first_circle_radius,
1361
+ self.first_obj.seventh_house.abs_pos,
1362
+ self.chart_colors_settings["paper_0"],
1363
+ )
1364
+ template_dict["background_circle"] = draw_background_circle(
1365
+ self.main_radius,
1366
+ self.chart_colors_settings["paper_1"],
1367
+ self.chart_colors_settings["paper_1"],
1368
+ )
1369
+ template_dict["first_circle"] = draw_first_circle(
1370
+ self.main_radius,
1371
+ self.chart_colors_settings["zodiac_radix_ring_2"],
1372
+ self.chart_type,
1373
+ self.first_circle_radius,
1374
+ )
1375
+ template_dict["second_circle"] = draw_second_circle(
1376
+ self.main_radius,
1377
+ self.chart_colors_settings["zodiac_radix_ring_1"],
1378
+ self.chart_colors_settings["paper_1"],
1379
+ self.chart_type,
1380
+ self.second_circle_radius,
1381
+ )
1382
+ template_dict["third_circle"] = draw_third_circle(
1383
+ self.main_radius,
1384
+ self.chart_colors_settings["zodiac_radix_ring_0"],
1385
+ self.chart_colors_settings["paper_1"],
1386
+ self.chart_type,
1387
+ self.third_circle_radius,
1388
+ )
1389
+
1390
+ # Aspects
1391
+ template_dict["makeDoubleChartAspectList"] = ""
1392
+ template_dict["makeAspectGrid"] = draw_aspect_grid(
1393
+ self.chart_colors_settings["paper_0"],
1394
+ self.available_planets_setting,
1395
+ self.aspects_list,
1396
+ )
1397
+ template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1398
+
1399
+ # Top left section
1400
+ # First subject
1401
+ latitude = convert_latitude_coordinate_to_string(
1402
+ self.first_obj.first_subject.lat, # type: ignore
1403
+ self._translate("north_letter", "N"),
1404
+ self._translate("south_letter", "S"),
1405
+ )
1406
+ longitude = convert_longitude_coordinate_to_string(
1407
+ self.first_obj.first_subject.lng, # type: ignore
1408
+ self._translate("east_letter", "E"),
1409
+ self._translate("west_letter", "W"),
1410
+ )
1411
+
1412
+ # Second subject
1413
+ latitude_string = convert_latitude_coordinate_to_string(
1414
+ self.first_obj.second_subject.lat, # type: ignore
1415
+ self._translate("north_letter", "N"),
1416
+ self._translate("south_letter", "S"),
1417
+ )
1418
+ longitude_string = convert_longitude_coordinate_to_string(
1419
+ self.first_obj.second_subject.lng, # type: ignore
1420
+ self._translate("east_letter", "E"),
1421
+ self._translate("west_letter", "W"),
1422
+ )
1423
+
1424
+ template_dict["top_left_0"] = f"{self.first_obj.first_subject.name}" # type: ignore
1425
+ template_dict["top_left_1"] = f"{datetime.fromisoformat(self.first_obj.first_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}" # type: ignore
1426
+ template_dict["top_left_2"] = f"{latitude} {longitude}"
1427
+ template_dict["top_left_3"] = self.first_obj.second_subject.name # type: ignore
1428
+ template_dict["top_left_4"] = f"{datetime.fromisoformat(self.first_obj.second_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}" # type: ignore
1429
+ template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
1430
+
1431
+ # Bottom left section
1432
+ if self.first_obj.zodiac_type == "Tropic":
1433
+ zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1434
+ else:
1435
+ mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1436
+ mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1437
+ zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1438
+
1439
+ template_dict["bottom_left_0"] = zodiac_info
1440
+ template_dict["bottom_left_1"] = f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self._translate('houses', 'Houses')}"
1441
+ template_dict["bottom_left_2"] = f'{self._translate("perspective_type", "Perspective")}: {self.first_obj.first_subject.perspective_type}' # type: ignore
1442
+ template_dict["bottom_left_3"] = f'{self._translate("composite_chart", "Composite Chart")} - {self._translate("midpoints", "Midpoints")}'
1443
+ template_dict["bottom_left_4"] = ""
1444
+
1445
+ # Moon phase section calculations
1446
+ if self.first_obj.lunar_phase is not None:
1447
+ template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1448
+ else:
1449
+ template_dict["makeLunarPhase"] = ""
1450
+
1451
+ # Houses and planet drawing
1452
+ template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1453
+ main_subject_houses_list=first_subject_houses_list,
1454
+ text_color=self.chart_colors_settings["paper_0"],
1455
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1456
+ )
1457
+ template_dict["makeSecondaryHousesGrid"] = ""
1458
+
1459
+ template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1460
+ r=self.main_radius,
1461
+ first_subject_houses_list=first_subject_houses_list,
1462
+ standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1463
+ first_house_color=self.planets_settings[12]["color"],
1464
+ tenth_house_color=self.planets_settings[13]["color"],
1465
+ seventh_house_color=self.planets_settings[14]["color"],
1466
+ fourth_house_color=self.planets_settings[15]["color"],
1467
+ c1=self.first_circle_radius,
1468
+ c3=self.third_circle_radius,
1469
+ chart_type=self.chart_type,
1470
+ external_view=self.external_view,
1471
+ )
1472
+
1473
+ template_dict["makePlanets"] = draw_planets(
1474
+ available_planets_setting=self.available_planets_setting,
1475
+ chart_type=self.chart_type,
1476
+ radius=self.main_radius,
1477
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1478
+ third_circle_radius=self.third_circle_radius,
1479
+ main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1480
+ main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1481
+ external_view=self.external_view,
1482
+ )
1483
+
1484
+ subject_name = (
1485
+ f"{self.first_obj.first_subject.name}"
1486
+ f" {self._translate('and_word', '&')} "
1487
+ f"{self.first_obj.second_subject.name}"
1488
+ ) # type: ignore
1489
+
1490
+ template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1491
+ planets_and_houses_grid_title=self._translate("planets_and_house", "Points for"),
1492
+ subject_name=subject_name,
1493
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1494
+ chart_type=self.chart_type,
1495
+ text_color=self.chart_colors_settings["paper_0"],
1496
+ celestial_point_language=self._language_model.celestial_points,
1497
+ )
1498
+ template_dict["makeSecondaryPlanetGrid"] = ""
1499
+ template_dict["makeHouseComparisonGrid"] = ""
1500
+
1501
+ elif self.chart_type == "Transit":
1502
+
1503
+ # Transit has no Element Percentages
1504
+ template_dict["elements_string"] = ""
1505
+ template_dict["fire_string"] = ""
1506
+ template_dict["earth_string"] = ""
1507
+ template_dict["air_string"] = ""
1508
+ template_dict["water_string"] = ""
1509
+
1510
+ # Transit has no Qualities Percentages
1511
+ template_dict["qualities_string"] = ""
1512
+ template_dict["cardinal_string"] = ""
1513
+ template_dict["fixed_string"] = ""
1514
+ template_dict["mutable_string"] = ""
1515
+
1516
+ # Set viewbox dynamically
1517
+ template_dict["viewbox"] = self._dynamic_viewbox()
1518
+
1519
+ # Get houses list for secondary subject
1520
+ second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1521
+
1522
+ # Rings and circles
1523
+ template_dict["transitRing"] = draw_transit_ring(
1524
+ self.main_radius,
1525
+ self.chart_colors_settings["paper_1"],
1526
+ self.chart_colors_settings["zodiac_transit_ring_3"],
1527
+ )
1528
+ template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1529
+ template_dict["background_circle"] = draw_background_circle(
1530
+ self.main_radius,
1531
+ self.chart_colors_settings["paper_1"],
1532
+ self.chart_colors_settings["paper_1"],
1533
+ )
1534
+ template_dict["first_circle"] = draw_first_circle(
1535
+ self.main_radius,
1536
+ self.chart_colors_settings["zodiac_transit_ring_2"],
1537
+ self.chart_type,
1538
+ )
1539
+ template_dict["second_circle"] = draw_second_circle(
1540
+ self.main_radius,
1541
+ self.chart_colors_settings["zodiac_transit_ring_1"],
1542
+ self.chart_colors_settings["paper_1"],
1543
+ self.chart_type,
1544
+ )
1545
+ template_dict["third_circle"] = draw_third_circle(
1546
+ self.main_radius,
1547
+ self.chart_colors_settings["zodiac_transit_ring_0"],
1548
+ self.chart_colors_settings["paper_1"],
1549
+ self.chart_type,
1550
+ self.third_circle_radius,
1551
+ )
1552
+
1553
+ # Aspects
1554
+ if self.double_chart_aspect_grid_type == "list":
1555
+ title = f'{self.first_obj.name} - {self._translate("transit_aspects", "Transit Aspects")}'
1556
+ template_dict["makeAspectGrid"] = ""
1557
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1558
+ title,
1559
+ self.aspects_list,
1560
+ self.planets_settings,
1561
+ self.aspects_settings,
1562
+ chart_height=self.height,
1563
+ ) # type: ignore[arg-type]
1564
+ else:
1565
+ template_dict["makeAspectGrid"] = ""
1566
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1567
+ self.chart_colors_settings["paper_0"],
1568
+ self.available_planets_setting,
1569
+ self.aspects_list,
1570
+ 600,
1571
+ 520,
1572
+ )
1573
+
1574
+ template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1575
+
1576
+ # Top left section
1577
+ latitude_string = convert_latitude_coordinate_to_string(self.geolat, self._translate("north", "North"), self._translate("south", "South"))
1578
+ longitude_string = convert_longitude_coordinate_to_string(self.geolon, self._translate("east", "East"), self._translate("west", "West"))
1579
+
1580
+ template_dict["top_left_0"] = f"{self.first_obj.name}"
1581
+ template_dict["top_left_1"] = f"{format_location_string(self.first_obj.city)}, {self.first_obj.nation}" # type: ignore
1582
+ template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1583
+ template_dict["top_left_3"] = f"{self._translate('latitude', 'Latitude')}: {latitude_string}"
1584
+ template_dict["top_left_4"] = f"{self._translate('longitude', 'Longitude')}: {longitude_string}"
1585
+ template_dict["top_left_5"] = ""#f"{self._translate('type', 'Type')}: {self._translate(self.chart_type, self.chart_type)}"
1586
+
1587
+ # Bottom left section
1588
+ if self.first_obj.zodiac_type == "Tropic":
1589
+ zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1590
+ else:
1591
+ mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1592
+ mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1593
+ zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1594
+
1595
+ template_dict["bottom_left_0"] = zodiac_info
1596
+ template_dict["bottom_left_1"] = f"{self._translate('domification', 'Domification')}: {self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1597
+
1598
+ # Lunar phase information from second object (Transit) (optional)
1599
+ if self.second_obj is not None and hasattr(self.second_obj, 'lunar_phase') and self.second_obj.lunar_phase is not None:
1600
+ template_dict["bottom_left_2"] = f'{self._translate("lunation_day", "Lunation Day")}: {self.second_obj.lunar_phase.get("moon_phase", "")}' # type: ignore
1601
+ template_dict["bottom_left_3"] = f'{self._translate("lunar_phase", "Lunar Phase")}: {self._translate(self.second_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.second_obj.lunar_phase.moon_phase_name)}'
1602
+ else:
1603
+ template_dict["bottom_left_2"] = ""
1604
+ template_dict["bottom_left_3"] = ""
1605
+
1606
+ template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.second_obj.perspective_type.lower().replace(" ", "_"), self.second_obj.perspective_type)}' # type: ignore
1607
+
1608
+ # Moon phase section calculations - use first_obj for visualization
1609
+ if self.first_obj.lunar_phase is not None:
1610
+ template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1611
+ else:
1612
+ template_dict["makeLunarPhase"] = ""
1613
+
1614
+ # Houses and planet drawing
1615
+ template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1616
+ main_subject_houses_list=first_subject_houses_list,
1617
+ text_color=self.chart_colors_settings["paper_0"],
1618
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1619
+ )
1620
+ # template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
1621
+ # secondary_subject_houses_list=second_subject_houses_list,
1622
+ # text_color=self.chart_colors_settings["paper_0"],
1623
+ # house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1624
+ # )
1625
+ template_dict["makeSecondaryHousesGrid"] = ""
1626
+
1627
+ template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1628
+ r=self.main_radius,
1629
+ first_subject_houses_list=first_subject_houses_list,
1630
+ standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1631
+ first_house_color=self.planets_settings[12]["color"],
1632
+ tenth_house_color=self.planets_settings[13]["color"],
1633
+ seventh_house_color=self.planets_settings[14]["color"],
1634
+ fourth_house_color=self.planets_settings[15]["color"],
1635
+ c1=self.first_circle_radius,
1636
+ c3=self.third_circle_radius,
1637
+ chart_type=self.chart_type,
1638
+ external_view=self.external_view,
1639
+ second_subject_houses_list=second_subject_houses_list,
1640
+ transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1641
+ )
1642
+
1643
+ template_dict["makePlanets"] = draw_planets(
1644
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1645
+ available_planets_setting=self.available_planets_setting,
1646
+ second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1647
+ radius=self.main_radius,
1648
+ main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1649
+ main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1650
+ chart_type=self.chart_type,
1651
+ third_circle_radius=self.third_circle_radius,
1652
+ external_view=self.external_view,
1653
+ )
1654
+
1655
+ # Planet grids
1656
+ first_name_label = self._truncate_name(self.first_obj.name)
1657
+ transit_label = self._translate("transit", "Transit")
1658
+ first_return_grid_title = f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})"
1659
+ second_return_grid_title = f"{transit_label} ({self._translate('outer_wheel', 'Outer Wheel')})"
1660
+ template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1661
+ planets_and_houses_grid_title="",
1662
+ subject_name=first_return_grid_title,
1663
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1664
+ chart_type=self.chart_type,
1665
+ text_color=self.chart_colors_settings["paper_0"],
1666
+ celestial_point_language=self._language_model.celestial_points,
1667
+ )
1668
+
1669
+ template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
1670
+ planets_and_houses_grid_title="",
1671
+ second_subject_name=second_return_grid_title,
1672
+ second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1673
+ chart_type=self.chart_type,
1674
+ text_color=self.chart_colors_settings["paper_0"],
1675
+ celestial_point_language=self._language_model.celestial_points,
1676
+ )
1677
+
1678
+ # House comparison grid
1679
+ house_comparison_factory = HouseComparisonFactory(
1680
+ first_subject=self.first_obj, # type: ignore[arg-type]
1681
+ second_subject=self.second_obj, # type: ignore[arg-type]
1682
+ active_points=self.active_points,
1683
+ )
1684
+ house_comparison = house_comparison_factory.get_house_comparison()
1685
+
1686
+ template_dict["makeHouseComparisonGrid"] = draw_single_house_comparison_grid(
1687
+ house_comparison,
1688
+ celestial_point_language=self._language_model.celestial_points,
1689
+ active_points=self.active_points,
1690
+ points_owner_subject_number=2, # The second subject is the Transit
1691
+ house_position_comparison_label=self._translate("house_position_comparison", "House Position Comparison"),
1692
+ return_point_label=self._translate("transit_point", "Transit Point"),
1693
+ natal_house_label=self._translate("house_position", "Natal House"),
1694
+ x_position=980,
1695
+ )
1696
+
1697
+ elif self.chart_type == "Synastry":
1698
+ # Set viewbox dynamically
1699
+ template_dict["viewbox"] = self._dynamic_viewbox()
1700
+
1701
+ # Get houses list for secondary subject
1702
+ second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1703
+
1704
+ # Rings and circles
1705
+ template_dict["transitRing"] = draw_transit_ring(
1706
+ self.main_radius,
1707
+ self.chart_colors_settings["paper_1"],
1708
+ self.chart_colors_settings["zodiac_transit_ring_3"],
1709
+ )
1710
+ template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1711
+ template_dict["background_circle"] = draw_background_circle(
1712
+ self.main_radius,
1713
+ self.chart_colors_settings["paper_1"],
1714
+ self.chart_colors_settings["paper_1"],
1715
+ )
1716
+ template_dict["first_circle"] = draw_first_circle(
1717
+ self.main_radius,
1718
+ self.chart_colors_settings["zodiac_transit_ring_2"],
1719
+ self.chart_type,
1720
+ )
1721
+ template_dict["second_circle"] = draw_second_circle(
1722
+ self.main_radius,
1723
+ self.chart_colors_settings["zodiac_transit_ring_1"],
1724
+ self.chart_colors_settings["paper_1"],
1725
+ self.chart_type,
1726
+ )
1727
+ template_dict["third_circle"] = draw_third_circle(
1728
+ self.main_radius,
1729
+ self.chart_colors_settings["zodiac_transit_ring_0"],
1730
+ self.chart_colors_settings["paper_1"],
1731
+ self.chart_type,
1732
+ self.third_circle_radius,
1733
+ )
1734
+
1735
+ # Aspects
1736
+ if self.double_chart_aspect_grid_type == "list":
1737
+ template_dict["makeAspectGrid"] = ""
1738
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1739
+ f"{self.first_obj.name} - {self.second_obj.name} {self._translate('synastry_aspects', 'Synastry Aspects')}", # type: ignore[union-attr]
1740
+ self.aspects_list,
1741
+ self.planets_settings, # type: ignore[arg-type]
1742
+ self.aspects_settings, # type: ignore[arg-type]
1743
+ chart_height=self.height,
1744
+ )
1745
+ else:
1746
+ template_dict["makeAspectGrid"] = ""
1747
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1748
+ self.chart_colors_settings["paper_0"],
1749
+ self.available_planets_setting,
1750
+ self.aspects_list,
1751
+ 550,
1752
+ 450,
1753
+ )
1754
+
1755
+ template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1756
+
1757
+ # Top left section
1758
+ template_dict["top_left_0"] = f"{self.first_obj.name}:"
1759
+ template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore
1760
+ template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1761
+ template_dict["top_left_3"] = f"{self.second_obj.name}: " # type: ignore
1762
+ template_dict["top_left_4"] = f"{self.second_obj.city}, {self.second_obj.nation}" # type: ignore
1763
+ template_dict["top_left_5"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore
1764
+
1765
+ # Bottom left section
1766
+ if self.first_obj.zodiac_type == "Tropic":
1767
+ zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1768
+ else:
1769
+ mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1770
+ mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1771
+ zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1772
+
1773
+ template_dict["bottom_left_0"] = ""
1774
+ # FIXME!
1775
+ template_dict["bottom_left_1"] = "" # f"Compatibility Score: {16}/44" # type: ignore
1776
+ template_dict["bottom_left_2"] = zodiac_info
1777
+ template_dict["bottom_left_3"] = f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self._translate('houses', 'Houses')}"
1778
+ template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1779
+
1780
+ # Moon phase section calculations
1781
+ template_dict["makeLunarPhase"] = ""
1782
+
1783
+ # Houses and planet drawing
1784
+ template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1785
+ main_subject_houses_list=first_subject_houses_list,
1786
+ text_color=self.chart_colors_settings["paper_0"],
1787
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1788
+ )
1789
+
1790
+ template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
1791
+ secondary_subject_houses_list=second_subject_houses_list,
1792
+ text_color=self.chart_colors_settings["paper_0"],
1793
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1794
+ )
1795
+
1796
+ template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1797
+ r=self.main_radius,
1798
+ first_subject_houses_list=first_subject_houses_list,
1799
+ standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1800
+ first_house_color=self.planets_settings[12]["color"],
1801
+ tenth_house_color=self.planets_settings[13]["color"],
1802
+ seventh_house_color=self.planets_settings[14]["color"],
1803
+ fourth_house_color=self.planets_settings[15]["color"],
1804
+ c1=self.first_circle_radius,
1805
+ c3=self.third_circle_radius,
1806
+ chart_type=self.chart_type,
1807
+ external_view=self.external_view,
1808
+ second_subject_houses_list=second_subject_houses_list,
1809
+ transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1810
+ )
1811
+
1812
+ template_dict["makePlanets"] = draw_planets(
1813
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1814
+ available_planets_setting=self.available_planets_setting,
1815
+ second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1816
+ radius=self.main_radius,
1817
+ main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1818
+ main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1819
+ chart_type=self.chart_type,
1820
+ third_circle_radius=self.third_circle_radius,
1821
+ external_view=self.external_view,
1822
+ )
1823
+
1824
+ # Planet grid
1825
+ first_name_label = self._truncate_name(self.first_obj.name, 18, "…") # type: ignore[union-attr]
1826
+ second_name_label = self._truncate_name(self.second_obj.name, 18, "…") # type: ignore[union-attr]
1827
+ template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1828
+ planets_and_houses_grid_title="",
1829
+ subject_name=f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})",
1830
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1831
+ chart_type=self.chart_type,
1832
+ text_color=self.chart_colors_settings["paper_0"],
1833
+ celestial_point_language=self._language_model.celestial_points,
1834
+ )
1835
+ template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
1836
+ planets_and_houses_grid_title="",
1837
+ second_subject_name= f"{second_name_label} ({self._translate('outer_wheel', 'Outer Wheel')})", # type: ignore
1838
+ second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1839
+ chart_type=self.chart_type,
1840
+ text_color=self.chart_colors_settings["paper_0"],
1841
+ celestial_point_language=self._language_model.celestial_points,
1842
+ )
1843
+ house_comparison_factory = HouseComparisonFactory(
1844
+ first_subject=self.first_obj, # type: ignore[arg-type]
1845
+ second_subject=self.second_obj, # type: ignore[arg-type]
1846
+ active_points=self.active_points,
1847
+ )
1848
+ house_comparison = house_comparison_factory.get_house_comparison()
1849
+
1850
+ first_subject_label = self._truncate_name(self.first_obj.name, 8, "…", True) # type: ignore[union-attr]
1851
+ second_subject_label = self._truncate_name(self.second_obj.name, 8, "…", True) # type: ignore[union-attr]
1852
+ point_column_label = self._translate("point", "Point")
1853
+ comparison_label = self._translate("house_position_comparison", "House Position Comparison")
1854
+
1855
+ first_subject_grid = draw_house_comparison_grid(
1856
+ house_comparison,
1857
+ celestial_point_language=self._language_model.celestial_points,
1858
+ active_points=self.active_points,
1859
+ points_owner_subject_number=1,
1860
+ house_position_comparison_label=comparison_label,
1861
+ return_point_label=first_subject_label + " " + point_column_label,
1862
+ return_label=first_subject_label,
1863
+ radix_label=second_subject_label,
1864
+ x_position=1090,
1865
+ y_position=0,
1866
+ )
1867
+
1868
+ second_subject_grid = draw_house_comparison_grid(
1869
+ house_comparison,
1870
+ celestial_point_language=self._language_model.celestial_points,
1871
+ active_points=self.active_points,
1872
+ points_owner_subject_number=2,
1873
+ house_position_comparison_label="",
1874
+ return_point_label=second_subject_label + " " + point_column_label,
1875
+ return_label=second_subject_label,
1876
+ radix_label=first_subject_label,
1877
+ x_position=1290,
1878
+ y_position=0,
1879
+ )
1880
+
1881
+ template_dict["makeHouseComparisonGrid"] = first_subject_grid + second_subject_grid
1882
+
1883
+ elif self.chart_type == "DualReturnChart":
1884
+ # Set viewbox dynamically
1885
+ template_dict["viewbox"] = self._dynamic_viewbox()
1886
+
1887
+ # Get houses list for secondary subject
1888
+ second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1889
+
1890
+ # Rings and circles
1891
+ template_dict["transitRing"] = draw_transit_ring(
1892
+ self.main_radius,
1893
+ self.chart_colors_settings["paper_1"],
1894
+ self.chart_colors_settings["zodiac_transit_ring_3"],
1895
+ )
1896
+ template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1897
+ template_dict["background_circle"] = draw_background_circle(
1898
+ self.main_radius,
1899
+ self.chart_colors_settings["paper_1"],
1900
+ self.chart_colors_settings["paper_1"],
1901
+ )
1902
+ template_dict["first_circle"] = draw_first_circle(
1903
+ self.main_radius,
1904
+ self.chart_colors_settings["zodiac_transit_ring_2"],
1905
+ self.chart_type,
1906
+ )
1907
+ template_dict["second_circle"] = draw_second_circle(
1908
+ self.main_radius,
1909
+ self.chart_colors_settings["zodiac_transit_ring_1"],
1910
+ self.chart_colors_settings["paper_1"],
1911
+ self.chart_type,
1912
+ )
1913
+ template_dict["third_circle"] = draw_third_circle(
1914
+ self.main_radius,
1915
+ self.chart_colors_settings["zodiac_transit_ring_0"],
1916
+ self.chart_colors_settings["paper_1"],
1917
+ self.chart_type,
1918
+ self.third_circle_radius,
1919
+ )
1920
+
1921
+ # Aspects
1922
+ if self.double_chart_aspect_grid_type == "list":
1923
+ title = self._translate("return_aspects", "Natal to Return Aspects")
1924
+ template_dict["makeAspectGrid"] = ""
1925
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1926
+ title,
1927
+ self.aspects_list,
1928
+ self.planets_settings,
1929
+ self.aspects_settings,
1930
+ max_columns=7,
1931
+ chart_height=self.height,
1932
+ ) # type: ignore[arg-type]
1933
+ else:
1934
+ template_dict["makeAspectGrid"] = ""
1935
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1936
+ self.chart_colors_settings["paper_0"],
1937
+ self.available_planets_setting,
1938
+ self.aspects_list,
1939
+ 550,
1940
+ 450,
1941
+ )
1942
+
1943
+ template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1944
+
1945
+
1946
+ # Top left section
1947
+ # Subject
1948
+ latitude_string = convert_latitude_coordinate_to_string(self.first_obj.lat, self._translate("north", "North"), self._translate("south", "South")) # type: ignore
1949
+ longitude_string = convert_longitude_coordinate_to_string(self.first_obj.lng, self._translate("east", "East"), self._translate("west", "West")) # type: ignore
1950
+
1951
+ # Return
1952
+ return_latitude_string = convert_latitude_coordinate_to_string(self.second_obj.lat, self._translate("north", "North"), self._translate("south", "South")) # type: ignore
1953
+ return_longitude_string = convert_longitude_coordinate_to_string(self.second_obj.lng, self._translate("east", "East"), self._translate("west", "West")) # type: ignore
1954
+
1955
+ if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1956
+ template_dict["top_left_0"] = f"{self._translate('solar_return', 'Solar Return')}:"
1957
+ else:
1958
+ template_dict["top_left_0"] = f"{self._translate('lunar_return', 'Lunar Return')}:"
1959
+ template_dict["top_left_1"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore
1960
+ template_dict["top_left_2"] = f"{return_latitude_string} / {return_longitude_string}"
1961
+ template_dict["top_left_3"] = f"{self.first_obj.name}"
1962
+ template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1963
+ template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
1964
+
1965
+ # Bottom left section
1966
+ if self.first_obj.zodiac_type == "Tropic":
1967
+ zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1968
+ else:
1969
+ mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1970
+ mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1971
+ zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1972
+
1973
+ template_dict["bottom_left_0"] = zodiac_info
1974
+ template_dict["bottom_left_1"] = f"{self._translate('domification', 'Domification')}: {self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1975
+
1976
+ # Lunar phase information (optional)
1977
+ if self.first_obj.lunar_phase is not None:
1978
+ template_dict["bottom_left_2"] = f'{self._translate("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
1979
+ template_dict["bottom_left_3"] = f'{self._translate("lunar_phase", "Lunar Phase")}: {self._translate(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
1980
+ else:
1981
+ template_dict["bottom_left_2"] = ""
1982
+ template_dict["bottom_left_3"] = ""
1983
+
1984
+ template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1985
+
1986
+ # Moon phase section calculations
1987
+ if self.first_obj.lunar_phase is not None:
1988
+ template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1989
+ else:
1990
+ template_dict["makeLunarPhase"] = ""
1991
+
1992
+ # Houses and planet drawing
1993
+ template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1994
+ main_subject_houses_list=first_subject_houses_list,
1995
+ text_color=self.chart_colors_settings["paper_0"],
1996
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1997
+ )
1998
+
1999
+ template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
2000
+ secondary_subject_houses_list=second_subject_houses_list,
2001
+ text_color=self.chart_colors_settings["paper_0"],
2002
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2003
+ )
2004
+
2005
+ template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
2006
+ r=self.main_radius,
2007
+ first_subject_houses_list=first_subject_houses_list,
2008
+ standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
2009
+ first_house_color=self.planets_settings[12]["color"],
2010
+ tenth_house_color=self.planets_settings[13]["color"],
2011
+ seventh_house_color=self.planets_settings[14]["color"],
2012
+ fourth_house_color=self.planets_settings[15]["color"],
2013
+ c1=self.first_circle_radius,
2014
+ c3=self.third_circle_radius,
2015
+ chart_type=self.chart_type,
2016
+ external_view=self.external_view,
2017
+ second_subject_houses_list=second_subject_houses_list,
2018
+ transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
2019
+ )
2020
+
2021
+ template_dict["makePlanets"] = draw_planets(
2022
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2023
+ available_planets_setting=self.available_planets_setting,
2024
+ second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
2025
+ radius=self.main_radius,
2026
+ main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
2027
+ main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
2028
+ chart_type=self.chart_type,
2029
+ third_circle_radius=self.third_circle_radius,
2030
+ external_view=self.external_view,
2031
+ )
2032
+
2033
+ # Planet grid
2034
+ first_name_label = self._truncate_name(self.first_obj.name)
2035
+ if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
2036
+ first_return_grid_title = f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})"
2037
+ second_return_grid_title = f"{self._translate('solar_return', 'Solar Return')} ({self._translate('outer_wheel', 'Outer Wheel')})"
2038
+ else:
2039
+ first_return_grid_title = f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})"
2040
+ second_return_grid_title = f'{self._translate("lunar_return", "Lunar Return")} ({self._translate("outer_wheel", "Outer Wheel")})'
2041
+ template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
2042
+ planets_and_houses_grid_title="",
2043
+ subject_name=first_return_grid_title,
2044
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2045
+ chart_type=self.chart_type,
2046
+ text_color=self.chart_colors_settings["paper_0"],
2047
+ celestial_point_language=self._language_model.celestial_points,
2048
+ )
2049
+ template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
2050
+ planets_and_houses_grid_title="",
2051
+ second_subject_name=second_return_grid_title,
2052
+ second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
2053
+ chart_type=self.chart_type,
2054
+ text_color=self.chart_colors_settings["paper_0"],
2055
+ celestial_point_language=self._language_model.celestial_points,
2056
+ )
2057
+
2058
+ house_comparison_factory = HouseComparisonFactory(
2059
+ first_subject=self.first_obj, # type: ignore[arg-type]
2060
+ second_subject=self.second_obj, # type: ignore[arg-type]
2061
+ active_points=self.active_points,
2062
+ )
2063
+ house_comparison = house_comparison_factory.get_house_comparison()
2064
+
2065
+ template_dict["makeHouseComparisonGrid"] = draw_house_comparison_grid(
2066
+ house_comparison,
2067
+ celestial_point_language=self._language_model.celestial_points,
2068
+ active_points=self.active_points,
2069
+ points_owner_subject_number=2, # The second subject is the Solar Return
2070
+ house_position_comparison_label=self._translate("house_position_comparison", "House Position Comparison"),
2071
+ return_point_label=self._translate("return_point", "Return Point"),
2072
+ return_label=self._translate("Return", "DualReturnChart"),
2073
+ radix_label=self._translate("Natal", "Natal"),
2074
+ )
2075
+
2076
+ elif self.chart_type == "SingleReturnChart":
2077
+ # Set viewbox dynamically
2078
+ template_dict["viewbox"] = self._dynamic_viewbox()
2079
+
2080
+ # Rings and circles
2081
+ template_dict["transitRing"] = ""
2082
+ template_dict["degreeRing"] = draw_degree_ring(
2083
+ self.main_radius,
2084
+ self.first_circle_radius,
2085
+ self.first_obj.seventh_house.abs_pos,
2086
+ self.chart_colors_settings["paper_0"],
2087
+ )
2088
+ template_dict["background_circle"] = draw_background_circle(
2089
+ self.main_radius,
2090
+ self.chart_colors_settings["paper_1"],
2091
+ self.chart_colors_settings["paper_1"],
2092
+ )
2093
+ template_dict["first_circle"] = draw_first_circle(
2094
+ self.main_radius,
2095
+ self.chart_colors_settings["zodiac_radix_ring_2"],
2096
+ self.chart_type,
2097
+ self.first_circle_radius,
2098
+ )
2099
+ template_dict["second_circle"] = draw_second_circle(
2100
+ self.main_radius,
2101
+ self.chart_colors_settings["zodiac_radix_ring_1"],
2102
+ self.chart_colors_settings["paper_1"],
2103
+ self.chart_type,
2104
+ self.second_circle_radius,
2105
+ )
2106
+ template_dict["third_circle"] = draw_third_circle(
2107
+ self.main_radius,
2108
+ self.chart_colors_settings["zodiac_radix_ring_0"],
2109
+ self.chart_colors_settings["paper_1"],
2110
+ self.chart_type,
2111
+ self.third_circle_radius,
2112
+ )
2113
+
2114
+ # Aspects
2115
+ template_dict["makeDoubleChartAspectList"] = ""
2116
+ template_dict["makeAspectGrid"] = draw_aspect_grid(
2117
+ self.chart_colors_settings["paper_0"],
2118
+ self.available_planets_setting,
2119
+ self.aspects_list,
2120
+ )
2121
+ template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
2122
+
2123
+ # Top left section
2124
+ latitude_string = convert_latitude_coordinate_to_string(self.geolat, self._translate("north", "North"), self._translate("south", "South"))
2125
+ longitude_string = convert_longitude_coordinate_to_string(self.geolon, self._translate("east", "East"), self._translate("west", "West"))
2126
+
2127
+ template_dict["top_left_0"] = f'{self._translate("info", "Info")}:'
2128
+ template_dict["top_left_1"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
2129
+ template_dict["top_left_2"] = f"{self.first_obj.city}, {self.first_obj.nation}"
2130
+ template_dict["top_left_3"] = f"{self._translate('latitude', 'Latitude')}: {latitude_string}"
2131
+ template_dict["top_left_4"] = f"{self._translate('longitude', 'Longitude')}: {longitude_string}"
2132
+
2133
+ if hasattr(self.first_obj, 'return_type') and self.first_obj.return_type == "Solar":
2134
+ template_dict["top_left_5"] = f"{self._translate('type', 'Type')}: {self._translate('solar_return', 'Solar Return')}"
2135
+ else:
2136
+ template_dict["top_left_5"] = f"{self._translate('type', 'Type')}: {self._translate('lunar_return', 'Lunar Return')}"
2137
+
2138
+ # Bottom left section
2139
+ if self.first_obj.zodiac_type == "Tropic":
2140
+ zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
2141
+ else:
2142
+ mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
2143
+ mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
2144
+ zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
2145
+
2146
+ template_dict["bottom_left_0"] = zodiac_info
2147
+ template_dict["bottom_left_1"] = f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self._translate('houses', 'Houses')}"
2148
+
2149
+ # Lunar phase information (optional)
2150
+ if self.first_obj.lunar_phase is not None:
2151
+ template_dict["bottom_left_2"] = f'{self._translate("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
2152
+ template_dict["bottom_left_3"] = f'{self._translate("lunar_phase", "Lunar Phase")}: {self._translate(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
2153
+ else:
2154
+ template_dict["bottom_left_2"] = ""
2155
+ template_dict["bottom_left_3"] = ""
2156
+
2157
+ template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
2158
+
2159
+ # Moon phase section calculations
2160
+ if self.first_obj.lunar_phase is not None:
2161
+ template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
2162
+ else:
2163
+ template_dict["makeLunarPhase"] = ""
2164
+
2165
+ # Houses and planet drawing
2166
+ template_dict["makeMainHousesGrid"] = draw_main_house_grid(
2167
+ main_subject_houses_list=first_subject_houses_list,
2168
+ text_color=self.chart_colors_settings["paper_0"],
2169
+ house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2170
+ )
2171
+ template_dict["makeSecondaryHousesGrid"] = ""
2172
+
2173
+ template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
2174
+ r=self.main_radius,
2175
+ first_subject_houses_list=first_subject_houses_list,
2176
+ standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
2177
+ first_house_color=self.planets_settings[12]["color"],
2178
+ tenth_house_color=self.planets_settings[13]["color"],
2179
+ seventh_house_color=self.planets_settings[14]["color"],
2180
+ fourth_house_color=self.planets_settings[15]["color"],
2181
+ c1=self.first_circle_radius,
2182
+ c3=self.third_circle_radius,
2183
+ chart_type=self.chart_type,
2184
+ external_view=self.external_view,
2185
+ )
2186
+
2187
+ template_dict["makePlanets"] = draw_planets(
2188
+ available_planets_setting=self.available_planets_setting,
2189
+ chart_type=self.chart_type,
2190
+ radius=self.main_radius,
2191
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2192
+ third_circle_radius=self.third_circle_radius,
2193
+ main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
2194
+ main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
2195
+ external_view=self.external_view,
2196
+ )
2197
+
2198
+ template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
2199
+ planets_and_houses_grid_title=self._translate("planets_and_house", "Points for"),
2200
+ subject_name=self.first_obj.name,
2201
+ available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2202
+ chart_type=self.chart_type,
2203
+ text_color=self.chart_colors_settings["paper_0"],
2204
+ celestial_point_language=self._language_model.celestial_points,
2205
+ )
2206
+ template_dict["makeSecondaryPlanetGrid"] = ""
2207
+ template_dict["makeHouseComparisonGrid"] = ""
2208
+
2209
+ return ChartTemplateModel(**template_dict)
2210
+
2211
+ def generate_svg_string(self, minify: bool = False, remove_css_variables=False) -> str:
2212
+ """
2213
+ Render the full chart SVG as a string.
2214
+
2215
+ Reads the XML template, substitutes variables, and optionally inlines CSS
2216
+ variables and minifies the output.
2217
+
2218
+ Args:
2219
+ minify (bool): Remove whitespace and quotes for compactness.
2220
+ remove_css_variables (bool): Embed CSS variable definitions.
2221
+
2222
+ Returns:
2223
+ str: SVG markup as a string.
2224
+ """
2225
+ td = self._create_template_dictionary()
2226
+
2227
+ DATA_DIR = Path(__file__).parent
2228
+ xml_svg = DATA_DIR / "templates" / "chart.xml"
2229
+
2230
+ # read template
2231
+ with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
2232
+ template = Template(f.read()).substitute(td.model_dump())
2233
+
2234
+ # return filename
2235
+
2236
+ logging.debug(f"Template dictionary has {len(td.model_dump())} fields")
2237
+
2238
+ self._create_template_dictionary()
2239
+
2240
+ if remove_css_variables:
2241
+ template = inline_css_variables_in_svg(template)
2242
+
2243
+ if minify:
2244
+ template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace(" ", "").replace(" ", "")
2245
+
2246
+ else:
2247
+ template = template.replace('"', "'")
2248
+
2249
+ return template
2250
+
2251
+ def save_svg(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2252
+ """
2253
+ Generate and save the full chart SVG to disk.
2254
+
2255
+ Calls generate_svg_string to render the SVG, then writes a file named
2256
+ "{subject.name} - {chart_type} Chart.svg" in the specified output directory.
2257
+
2258
+ Args:
2259
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
2260
+ If None, defaults to the user's home directory.
2261
+ filename (str or None): Custom filename for the SVG file (without extension).
2262
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart".
2263
+ minify (bool): Pass-through to generate_svg_string for compact output.
2264
+ remove_css_variables (bool): Pass-through to generate_svg_string to embed CSS variables.
2265
+
2266
+ Returns:
2267
+ None
2268
+ """
2269
+
2270
+ self.template = self.generate_svg_string(minify, remove_css_variables)
2271
+
2272
+ # Convert output_path to Path object, default to home directory
2273
+ output_directory = Path(output_path) if output_path is not None else Path.home()
2274
+
2275
+ # Determine filename
2276
+ if filename is not None:
2277
+ chartname = output_directory / f"{filename}.svg"
2278
+ else:
2279
+ # Use default filename pattern
2280
+ chart_type_for_filename = self.chart_type
2281
+
2282
+ if self.chart_type == "DualReturnChart" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar":
2283
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Lunar Return.svg"
2284
+ elif self.chart_type == "DualReturnChart" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
2285
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Solar Return.svg"
2286
+ else:
2287
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart.svg"
2288
+
2289
+ with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2290
+ output_file.write(self.template)
2291
+
2292
+ print(f"SVG Generated Correctly in: {chartname}")
2293
+
2294
+ def generate_wheel_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2295
+ """
2296
+ Render the wheel-only chart SVG as a string.
2297
+
2298
+ Reads the wheel-only XML template, substitutes chart data, and applies optional
2299
+ CSS inlining and minification.
2300
+
2301
+ Args:
2302
+ minify (bool): Remove whitespace and quotes for compactness.
2303
+ remove_css_variables (bool): Embed CSS variable definitions.
2304
+
2305
+ Returns:
2306
+ str: SVG markup for the chart wheel only.
2307
+ """
2308
+
2309
+ with open(
2310
+ Path(__file__).parent / "templates" / "wheel_only.xml",
2311
+ "r",
2312
+ encoding="utf-8",
2313
+ errors="ignore",
2314
+ ) as f:
2315
+ template = f.read()
2316
+
2317
+ template_dict = self._create_template_dictionary()
2318
+ # Use a compact viewBox specific for the wheel-only rendering
2319
+ wheel_viewbox = self._wheel_only_viewbox()
2320
+ template = Template(template).substitute({**template_dict.model_dump(), "viewbox": wheel_viewbox})
2321
+
2322
+ if remove_css_variables:
2323
+ template = inline_css_variables_in_svg(template)
2324
+
2325
+ if minify:
2326
+ template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace(" ", "").replace(" ", "")
2327
+
2328
+ else:
2329
+ template = template.replace('"', "'")
2330
+
2331
+ return template
2332
+
2333
+ def save_wheel_only_svg_file(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2334
+ """
2335
+ Generate and save wheel-only chart SVG to disk.
2336
+
2337
+ Calls generate_wheel_only_svg_string and writes a file named
2338
+ "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the specified output directory.
2339
+
2340
+ Args:
2341
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
2342
+ If None, defaults to the user's home directory.
2343
+ filename (str or None): Custom filename for the SVG file (without extension).
2344
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Wheel Only".
2345
+ minify (bool): Pass-through to generate_wheel_only_svg_string for compact output.
2346
+ remove_css_variables (bool): Pass-through to generate_wheel_only_svg_string to embed CSS variables.
2347
+
2348
+ Returns:
2349
+ None
2350
+ """
2351
+
2352
+ template = self.generate_wheel_only_svg_string(minify, remove_css_variables)
2353
+
2354
+ # Convert output_path to Path object, default to home directory
2355
+ output_directory = Path(output_path) if output_path is not None else Path.home()
2356
+
2357
+ # Determine filename
2358
+ if filename is not None:
2359
+ chartname = output_directory / f"{filename}.svg"
2360
+ else:
2361
+ # Use default filename pattern
2362
+ chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2363
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Wheel Only.svg"
2364
+
2365
+ with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2366
+ output_file.write(template)
2367
+
2368
+ print(f"SVG Generated Correctly in: {chartname}")
2369
+
2370
+ def generate_aspect_grid_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2371
+ """
2372
+ Render the aspect-grid-only chart SVG as a string.
2373
+
2374
+ Reads the aspect-grid XML template, generates the aspect grid based on chart type,
2375
+ and applies optional CSS inlining and minification.
2376
+
2377
+ Args:
2378
+ minify (bool): Remove whitespace and quotes for compactness.
2379
+ remove_css_variables (bool): Embed CSS variable definitions.
2380
+
2381
+ Returns:
2382
+ str: SVG markup for the aspect grid only.
2383
+ """
2384
+
2385
+ with open(
2386
+ Path(__file__).parent / "templates" / "aspect_grid_only.xml",
2387
+ "r",
2388
+ encoding="utf-8",
2389
+ errors="ignore",
2390
+ ) as f:
2391
+ template = f.read()
2392
+
2393
+ template_dict = self._create_template_dictionary()
2394
+
2395
+ if self.chart_type in ["Transit", "Synastry", "DualReturnChart"]:
2396
+ aspects_grid = draw_transit_aspect_grid(
2397
+ self.chart_colors_settings["paper_0"],
2398
+ self.available_planets_setting,
2399
+ self.aspects_list,
2400
+ )
2401
+ else:
2402
+ aspects_grid = draw_aspect_grid(
2403
+ self.chart_colors_settings["paper_0"],
2404
+ self.available_planets_setting,
2405
+ self.aspects_list,
2406
+ x_start=50,
2407
+ y_start=250,
2408
+ )
2409
+
2410
+ # Use a compact, known-good viewBox that frames the grid
2411
+ viewbox_override = self._grid_only_viewbox()
2412
+
2413
+ template = Template(template).substitute({**template_dict.model_dump(), "makeAspectGrid": aspects_grid, "viewbox": viewbox_override})
2414
+
2415
+ if remove_css_variables:
2416
+ template = inline_css_variables_in_svg(template)
2417
+
2418
+ if minify:
2419
+ template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace(" ", "").replace(" ", "")
2420
+
2421
+ else:
2422
+ template = template.replace('"', "'")
2423
+
2424
+ return template
2425
+
2426
+ def save_aspect_grid_only_svg_file(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2427
+ """
2428
+ Generate and save aspect-grid-only chart SVG to disk.
2429
+
2430
+ Calls generate_aspect_grid_only_svg_string and writes a file named
2431
+ "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the specified output directory.
2432
+
2433
+ Args:
2434
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
2435
+ If None, defaults to the user's home directory.
2436
+ filename (str or None): Custom filename for the SVG file (without extension).
2437
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Aspect Grid Only".
2438
+ minify (bool): Pass-through to generate_aspect_grid_only_svg_string for compact output.
2439
+ remove_css_variables (bool): Pass-through to generate_aspect_grid_only_svg_string to embed CSS variables.
2440
+
2441
+ Returns:
2442
+ None
2443
+ """
2444
+
2445
+ template = self.generate_aspect_grid_only_svg_string(minify, remove_css_variables)
2446
+
2447
+ # Convert output_path to Path object, default to home directory
2448
+ output_directory = Path(output_path) if output_path is not None else Path.home()
2449
+
2450
+ # Determine filename
2451
+ if filename is not None:
2452
+ chartname = output_directory / f"{filename}.svg"
2453
+ else:
2454
+ # Use default filename pattern
2455
+ chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2456
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Aspect Grid Only.svg"
2457
+
2458
+ with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2459
+ output_file.write(template)
2460
+
2461
+ print(f"SVG Generated Correctly in: {chartname}")
2462
+
2463
+ if __name__ == "__main__":
2464
+ from kerykeion.utilities import setup_logging
2465
+ from kerykeion.planetary_return_factory import PlanetaryReturnFactory
2466
+ from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
2467
+ from kerykeion.chart_data_factory import ChartDataFactory
2468
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
2469
+
2470
+ ACTIVE_PLANETS: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS
2471
+ # ACTIVE_PLANETS: list[AstrologicalPoint] = ALL_ACTIVE_POINTS
2472
+ setup_logging(level="info")
2473
+
2474
+ subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB", active_points=ACTIVE_PLANETS)
2475
+
2476
+ return_factory = PlanetaryReturnFactory(
2477
+ subject,
2478
+ city="Los Angeles",
2479
+ nation="US",
2480
+ lng=-118.2437,
2481
+ lat=34.0522,
2482
+ tz_str="America/Los_Angeles",
2483
+ altitude=0
2484
+ )
2485
+
2486
+ ###
2487
+ ## Birth Chart - NEW APPROACH with ChartDataFactory
2488
+ birth_chart_data = ChartDataFactory.create_natal_chart_data(
2489
+ subject,
2490
+ active_points=ACTIVE_PLANETS,
2491
+ )
2492
+ birth_chart = ChartDrawer(
2493
+ chart_data=birth_chart_data,
2494
+ chart_language="IT",
2495
+ theme="strawberry",
2496
+ )
2497
+ birth_chart.save_svg() # minify=True, remove_css_variables=True)
2498
+
2499
+ ###
2500
+ ## Solar Return Chart - NEW APPROACH with ChartDataFactory
2501
+ solar_return = return_factory.next_return_from_iso_formatted_time(
2502
+ "2025-01-09T18:30:00+01:00", # UTC+1
2503
+ return_type="Solar",
2504
+ )
2505
+ solar_return_chart_data = ChartDataFactory.create_return_chart_data(
2506
+ subject,
2507
+ solar_return,
2508
+ active_points=ACTIVE_PLANETS,
2509
+ )
2510
+ solar_return_chart = ChartDrawer(
2511
+ chart_data=solar_return_chart_data,
2512
+ chart_language="IT",
2513
+ theme="classic",
2514
+ )
2515
+
2516
+ solar_return_chart.save_svg() # minify=True, remove_css_variables=True)
2517
+
2518
+ ###
2519
+ ## Single wheel return - NEW APPROACH with ChartDataFactory
2520
+ single_wheel_return_chart_data = ChartDataFactory.create_single_wheel_return_chart_data(
2521
+ solar_return,
2522
+ active_points=ACTIVE_PLANETS,
2523
+ )
2524
+ single_wheel_return_chart = ChartDrawer(
2525
+ chart_data=single_wheel_return_chart_data,
2526
+ chart_language="IT",
2527
+ theme="dark",
2528
+ )
2529
+
2530
+ single_wheel_return_chart.save_svg() # minify=True, remove_css_variables=True)
2531
+
2532
+ ###
2533
+ ## Lunar return - NEW APPROACH with ChartDataFactory
2534
+ lunar_return = return_factory.next_return_from_iso_formatted_time(
2535
+ "2025-01-09T18:30:00+01:00", # UTC+1
2536
+ return_type="Lunar",
2537
+ )
2538
+ lunar_return_chart_data = ChartDataFactory.create_return_chart_data(
2539
+ subject,
2540
+ lunar_return,
2541
+ active_points=ACTIVE_PLANETS,
2542
+ )
2543
+ lunar_return_chart = ChartDrawer(
2544
+ chart_data=lunar_return_chart_data,
2545
+ chart_language="IT",
2546
+ theme="dark",
2547
+ )
2548
+ lunar_return_chart.save_svg() # minify=True, remove_css_variables=True)
2549
+
2550
+ ###
2551
+ ## Transit Chart - NEW APPROACH with ChartDataFactory
2552
+ transit = AstrologicalSubjectFactory.from_iso_utc_time(
2553
+ "Transit",
2554
+ "2021-10-04T18:30:00+01:00",
2555
+ )
2556
+ transit_chart_data = ChartDataFactory.create_transit_chart_data(
2557
+ subject,
2558
+ transit,
2559
+ active_points=ACTIVE_PLANETS,
2560
+ )
2561
+ transit_chart = ChartDrawer(
2562
+ chart_data=transit_chart_data,
2563
+ chart_language="IT",
2564
+ theme="dark",
2565
+ )
2566
+ transit_chart.save_svg() # minify=True, remove_css_variables=True)
2567
+
2568
+ ###
2569
+ ## Synastry Chart - NEW APPROACH with ChartDataFactory
2570
+ second_subject = AstrologicalSubjectFactory.from_birth_data("Yoko Ono", 1933, 2, 18, 18, 30, "Tokyo", "JP")
2571
+ synastry_chart_data = ChartDataFactory.create_synastry_chart_data(
2572
+ subject,
2573
+ second_subject,
2574
+ active_points=ACTIVE_PLANETS,
2575
+ )
2576
+ synastry_chart = ChartDrawer(
2577
+ chart_data=synastry_chart_data,
2578
+ chart_language="IT",
2579
+ theme="dark",
2580
+ )
2581
+ synastry_chart.save_svg() # minify=True, remove_css_variables=True)
2582
+
2583
+ ##
2584
+ # Transit Chart with Grid - NEW APPROACH with ChartDataFactory
2585
+ subject.name = "Grid"
2586
+ transit_chart_with_grid_data = ChartDataFactory.create_transit_chart_data(
2587
+ subject,
2588
+ transit,
2589
+ active_points=ACTIVE_PLANETS,
2590
+ )
2591
+ transit_chart_with_grid = ChartDrawer(
2592
+ chart_data=transit_chart_with_grid_data,
2593
+ chart_language="IT",
2594
+ theme="dark",
2595
+ double_chart_aspect_grid_type="table"
2596
+ )
2597
+ transit_chart_with_grid.save_svg() # minify=True, remove_css_variables=True)
2598
+ transit_chart_with_grid.save_aspect_grid_only_svg_file()
2599
+ transit_chart_with_grid.save_wheel_only_svg_file()
2600
+
2601
+ print("✅ All chart examples completed using ChartDataFactory + ChartDrawer architecture!")