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