kerykeion 4.18.3__py3-none-any.whl → 5.1.9__py3-none-any.whl

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

Potentially problematic release.


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

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