kerykeion 5.0.0a12__py3-none-any.whl → 5.0.0b2__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 (51) hide show
  1. kerykeion/__init__.py +30 -6
  2. kerykeion/aspects/aspects_factory.py +40 -24
  3. kerykeion/aspects/aspects_utils.py +75 -6
  4. kerykeion/astrological_subject_factory.py +377 -226
  5. kerykeion/backword.py +680 -0
  6. kerykeion/chart_data_factory.py +484 -0
  7. kerykeion/charts/{kerykeion_chart_svg.py → chart_drawer.py} +688 -440
  8. kerykeion/charts/charts_utils.py +157 -94
  9. kerykeion/charts/draw_planets.py +38 -28
  10. kerykeion/charts/templates/aspect_grid_only.xml +188 -17
  11. kerykeion/charts/templates/chart.xml +153 -47
  12. kerykeion/charts/templates/wheel_only.xml +195 -24
  13. kerykeion/charts/themes/classic.css +11 -0
  14. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  15. kerykeion/charts/themes/dark.css +11 -0
  16. kerykeion/charts/themes/light.css +11 -0
  17. kerykeion/charts/themes/strawberry.css +10 -0
  18. kerykeion/composite_subject_factory.py +4 -4
  19. kerykeion/ephemeris_data_factory.py +12 -9
  20. kerykeion/house_comparison/__init__.py +0 -3
  21. kerykeion/house_comparison/house_comparison_factory.py +3 -3
  22. kerykeion/house_comparison/house_comparison_utils.py +3 -4
  23. kerykeion/planetary_return_factory.py +8 -4
  24. kerykeion/relationship_score_factory.py +3 -3
  25. kerykeion/report.py +748 -67
  26. kerykeion/{kr_types → schemas}/__init__.py +44 -4
  27. kerykeion/schemas/chart_template_model.py +367 -0
  28. kerykeion/{kr_types → schemas}/kr_literals.py +7 -3
  29. kerykeion/{kr_types → schemas}/kr_models.py +220 -11
  30. kerykeion/{kr_types → schemas}/settings_models.py +7 -7
  31. kerykeion/settings/config_constants.py +75 -8
  32. kerykeion/settings/kerykeion_settings.py +1 -1
  33. kerykeion/settings/kr.config.json +132 -42
  34. kerykeion/settings/legacy/legacy_celestial_points_settings.py +8 -8
  35. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  36. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  37. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  38. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  39. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  40. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  41. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  42. kerykeion/transits_time_range_factory.py +7 -7
  43. kerykeion/utilities.py +61 -38
  44. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/METADATA +507 -120
  45. kerykeion-5.0.0b2.dist-info/RECORD +58 -0
  46. kerykeion/house_comparison/house_comparison_models.py +0 -76
  47. kerykeion/kr_types/chart_types.py +0 -106
  48. kerykeion-5.0.0a12.dist-info/RECORD +0 -50
  49. /kerykeion/{kr_types → schemas}/kerykeion_exception.py +0 -0
  50. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/WHEEL +0 -0
  51. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/licenses/LICENSE +0 -0
@@ -6,33 +6,34 @@
6
6
 
7
7
  import logging
8
8
  import swisseph as swe
9
- from typing import get_args, Union, Optional
9
+ from typing import get_args, Union, Any
10
10
 
11
+
12
+ from kerykeion.schemas.kr_models import ChartDataModel
13
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
11
14
  from kerykeion.settings.kerykeion_settings import get_settings
12
- from kerykeion.aspects import AspectsFactory
13
15
  from kerykeion.house_comparison.house_comparison_factory import HouseComparisonFactory
14
- from kerykeion.kr_types import (
16
+ from kerykeion.schemas import (
15
17
  KerykeionException,
16
18
  ChartType,
17
19
  Sign,
18
20
  ActiveAspect,
19
21
  )
20
- from kerykeion.kr_types import ChartTemplateDictionary
21
- from kerykeion.kr_types.kr_models import (
22
+ from kerykeion.schemas import ChartTemplateModel
23
+ from kerykeion.schemas.kr_models import (
22
24
  AstrologicalSubjectModel,
23
25
  CompositeSubjectModel,
24
26
  PlanetReturnModel,
25
27
  )
26
- from kerykeion.kr_types.settings_models import (
28
+ from kerykeion.schemas.settings_models import (
27
29
  KerykeionSettingsCelestialPointModel,
28
30
  KerykeionSettingsModel,
29
31
  )
30
- from kerykeion.kr_types.kr_literals import (
32
+ from kerykeion.schemas.kr_literals import (
31
33
  KerykeionChartTheme,
32
34
  KerykeionChartLanguage,
33
35
  AstrologicalPoint,
34
36
  )
35
- from kerykeion.utilities import find_common_active_points
36
37
  from kerykeion.charts.charts_utils import (
37
38
  draw_zodiac_slice,
38
39
  convert_latitude_coordinate_to_string,
@@ -57,15 +58,10 @@ from kerykeion.charts.charts_utils import (
57
58
  draw_main_planet_grid,
58
59
  draw_secondary_planet_grid,
59
60
  format_location_string,
60
- format_datetime_with_timezone,
61
- calculate_element_points,
62
- calculate_synastry_element_points,
63
- calculate_quality_points,
64
- calculate_synastry_quality_points
61
+ format_datetime_with_timezone
65
62
  )
66
63
  from kerykeion.charts.draw_planets import draw_planets
67
- from kerykeion.utilities import get_houses_list, inline_css_variables_in_svg
68
- from kerykeion.settings.config_constants import DEFAULT_ACTIVE_ASPECTS
64
+ from kerykeion.utilities import get_houses_list, inline_css_variables_in_svg, distribute_percentages_to_100
69
65
  from kerykeion.settings.legacy.legacy_color_settings import DEFAULT_CHART_COLORS
70
66
  from kerykeion.settings.legacy.legacy_celestial_points_settings import DEFAULT_CELESTIAL_POINTS_SETTINGS
71
67
  from kerykeion.settings.legacy.legacy_chart_aspects_settings import DEFAULT_CHART_ASPECTS_SETTINGS
@@ -76,30 +72,32 @@ from typing import List, Literal
76
72
  from datetime import datetime
77
73
 
78
74
 
79
- class KerykeionChartSVG:
75
+ class ChartDrawer:
80
76
  """
81
- KerykeionChartSVG generates astrological chart visualizations as SVG files.
77
+ ChartDrawer generates astrological chart visualizations as SVG files from pre-computed chart data.
78
+
79
+ This class is designed for pure visualization and requires chart data to be pre-computed using
80
+ ChartDataFactory. This separation ensures clean architecture where ChartDataFactory handles
81
+ all calculations (aspects, element/quality distributions, subjects) while ChartDrawer focuses
82
+ solely on rendering SVG visualizations.
82
83
 
83
- This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
84
- for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite.
84
+ ChartDrawer supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
85
+ for various chart types including Natal, Transit, Synastry, and Composite.
85
86
  Charts are rendered using XML templates and drawing utilities, with customizable themes,
86
- language, active points, and aspects.
87
- The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.
87
+ language, and visual settings.
88
+
89
+ The generated SVG files are optimized for web use and can be saved to any specified
90
+ destination path using the save_svg method.
88
91
 
89
92
  NOTE:
90
93
  The generated SVG files are optimized for web use, opening in browsers. If you want to
91
94
  use them in other applications, you might need to adjust the SVG settings or styles.
92
95
 
93
96
  Args:
94
- first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel):
95
- The primary astrological subject for the chart.
96
- chart_type (ChartType, optional):
97
- The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite').
98
- Defaults to 'Natal'.
99
- second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional):
100
- The secondary subject for Transit or Synastry charts. Not required for Natal or Composite.
101
- new_output_directory (str | Path, optional):
102
- Directory to write generated SVG files. Defaults to the user's home directory.
97
+ chart_data (ChartDataModel):
98
+ Pre-computed chart data from ChartDataFactory containing all subjects, aspects,
99
+ element/quality distributions, and other analytical data. This is the ONLY source
100
+ of chart information - no calculations are performed by ChartDrawer.
103
101
  new_settings_file (Path | dict | KerykeionSettingsModel, optional):
104
102
  Path or settings object to override default chart configuration (colors, fonts, aspects).
105
103
  theme (KerykeionChartTheme, optional):
@@ -108,55 +106,75 @@ class KerykeionChartSVG:
108
106
  Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
109
107
  chart_language (KerykeionChartLanguage, optional):
110
108
  Language code for chart labels. Defaults to 'EN'.
111
- active_points (list[AstrologicalPoint], optional):
112
- List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS.
113
- Example:
114
- ["Sun", "Moon", "Mercury", "Venus"]
115
-
116
- active_aspects (list[ActiveAspect], optional):
117
- List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
118
- Example:
119
- [
120
- {"name": "conjunction", "orb": 10},
121
- {"name": "opposition", "orb": 10},
122
- {"name": "trine", "orb": 8},
123
- {"name": "sextile", "orb": 6},
124
- {"name": "square", "orb": 5},
125
- {"name": "quintile", "orb": 1},
126
- ]
109
+ transparent_background (bool, optional):
110
+ Whether to use a transparent background instead of the theme color. Defaults to False.
127
111
 
128
112
  Public Methods:
129
113
  makeTemplate(minify=False, remove_css_variables=False) -> str:
130
114
  Render the full chart SVG as a string without writing to disk. Use `minify=True`
131
115
  to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
132
116
 
133
- makeSVG(minify=False, remove_css_variables=False) -> None:
134
- Generate and write the full chart SVG file to the output directory.
135
- Filenames follow the pattern:
136
- '{subject.name} - {chart_type} Chart.svg'.
117
+ save_svg(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
118
+ Generate and write the full chart SVG file to the specified path.
119
+ If output_path is None, saves to the user's home directory.
120
+ If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart.svg'.
137
121
 
138
122
  makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
139
123
  Render only the chart wheel (no aspect grid) as an SVG string.
140
124
 
141
- makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
142
- Generate and write the wheel-only SVG file:
143
- '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
125
+ save_wheel_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
126
+ Generate and write the wheel-only SVG file to the specified path.
127
+ If output_path is None, saves to the user's home directory.
128
+ If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
144
129
 
145
130
  makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
146
131
  Render only the aspect grid as an SVG string.
147
132
 
148
- makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
149
- Generate and write the aspect-grid-only SVG file:
150
- '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
133
+ save_aspect_grid_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
134
+ Generate and write the aspect-grid-only SVG file to the specified path.
135
+ If output_path is None, saves to the user's home directory.
136
+ If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
137
+
138
+ Example:
139
+ >>> from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
140
+ >>> from kerykeion.chart_data_factory import ChartDataFactory
141
+ >>> from kerykeion.charts.chart_drawer import ChartDrawer
142
+ >>>
143
+ >>> # Step 1: Create subject
144
+ >>> subject = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
145
+ >>>
146
+ >>> # Step 2: Pre-compute chart data
147
+ >>> chart_data = ChartDataFactory.create_natal_chart_data(subject)
148
+ >>>
149
+ >>> # Step 3: Create visualization
150
+ >>> chart_drawer = ChartDrawer(chart_data=chart_data, theme="classic")
151
+ >>> chart_drawer.save_svg() # Saves to home directory with default filename
152
+ >>> # Or specify custom path and filename:
153
+ >>> chart_drawer.save_svg("/path/to/output/directory", "my_custom_chart")
151
154
  """
152
155
 
153
156
  # Constants
154
157
 
155
158
  _DEFAULT_HEIGHT = 550
156
- _DEFAULT_FULL_WIDTH = 1200
159
+ _DEFAULT_FULL_WIDTH = 1250
157
160
  _DEFAULT_NATAL_WIDTH = 870
158
- _DEFAULT_FULL_WIDTH_WITH_TABLE = 1200
159
- _DEFAULT_ULTRA_WIDE_WIDTH = 1270
161
+ _DEFAULT_FULL_WIDTH_WITH_TABLE = 1250
162
+ _DEFAULT_ULTRA_WIDE_WIDTH = 1320
163
+
164
+ _BASE_VERTICAL_OFFSETS = {
165
+ "wheel": 50,
166
+ "grid": 0,
167
+ "aspect_grid": 50,
168
+ "aspect_list": 50,
169
+ "title": 0,
170
+ "elements": 0,
171
+ "qualities": 0,
172
+ "lunar_phase": 518,
173
+ "bottom_left": 0,
174
+ }
175
+ _MAX_TOP_SHIFT = 80
176
+ _TOP_SHIFT_FACTOR = 2
177
+ _ROW_HEIGHT = 8
160
178
 
161
179
  _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}"
162
180
  _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0"
@@ -167,9 +185,6 @@ class KerykeionChartSVG:
167
185
  first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
168
186
  second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None]
169
187
  chart_type: ChartType
170
- new_output_directory: Union[Path, None]
171
- new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
172
- output_directory: Path
173
188
  new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
174
189
  theme: Union[KerykeionChartTheme, None]
175
190
  double_chart_aspect_grid_type: Literal["list", "table"]
@@ -177,6 +192,8 @@ class KerykeionChartSVG:
177
192
  active_points: List[AstrologicalPoint]
178
193
  active_aspects: List[ActiveAspect]
179
194
  transparent_background: bool
195
+ external_view: bool
196
+ custom_title: Union[str, None]
180
197
 
181
198
  # Internal properties
182
199
  fire: float
@@ -189,8 +206,8 @@ class KerykeionChartSVG:
189
206
  width: Union[float, int]
190
207
  language_settings: dict
191
208
  chart_colors_settings: dict
192
- planets_settings: dict
193
- aspects_settings: dict
209
+ planets_settings: list[dict[Any, Any]]
210
+ aspects_settings: list[dict[Any, Any]]
194
211
  available_planets_setting: List[KerykeionSettingsCelestialPointModel]
195
212
  height: float
196
213
  location: str
@@ -200,34 +217,28 @@ class KerykeionChartSVG:
200
217
 
201
218
  def __init__(
202
219
  self,
203
- first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
204
- chart_type: ChartType = "Natal",
205
- second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None] = None,
206
- new_output_directory: Union[str, None] = None,
220
+ chart_data: "ChartDataModel",
221
+ *,
207
222
  new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
208
223
  theme: Union[KerykeionChartTheme, None] = "classic",
209
224
  double_chart_aspect_grid_type: Literal["list", "table"] = "list",
210
225
  chart_language: KerykeionChartLanguage = "EN",
211
- active_points: Optional[list[AstrologicalPoint]] = None,
212
- active_aspects: list[ActiveAspect]= DEFAULT_ACTIVE_ASPECTS,
213
- *,
226
+ external_view: bool = False,
214
227
  transparent_background: bool = False,
215
228
  colors_settings: dict = DEFAULT_CHART_COLORS,
216
229
  celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
217
230
  aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
231
+ custom_title: Union[str, None] = None,
232
+ auto_size: bool = True,
233
+ padding: int = 20,
218
234
  ):
219
235
  """
220
- Initialize the chart generator with subject data and configuration options.
236
+ Initialize the chart visualizer with pre-computed chart data.
221
237
 
222
238
  Args:
223
- first_obj (AstrologicalSubjectModel, or CompositeSubjectModel):
224
- Primary astrological subject instance.
225
- chart_type (ChartType, optional):
226
- Type of chart to generate (e.g., 'Natal', 'Transit').
227
- second_obj (AstrologicalSubject, optional):
228
- Secondary subject for Transit or Synastry charts.
229
- new_output_directory (str or Path, optional):
230
- Base directory to save generated SVG files.
239
+ chart_data (ChartDataModel):
240
+ Pre-computed chart data from ChartDataFactory containing all subjects,
241
+ aspects, element/quality distributions, and other analytical data.
231
242
  new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
232
243
  Custom settings source for chart colors, fonts, and aspects.
233
244
  theme (KerykeionChartTheme or None, optional):
@@ -236,99 +247,91 @@ class KerykeionChartSVG:
236
247
  Layout style for double-chart aspect grids ('list' or 'table').
237
248
  chart_language (KerykeionChartLanguage, optional):
238
249
  Language code for chart labels (e.g., 'EN', 'IT').
239
- active_points (List[AstrologicalPoint], optional):
240
- Celestial points to include in the chart visualization.
241
- active_aspects (List[ActiveAspect], optional):
242
- Aspects to calculate, each defined by name and orb.
250
+ external_view (bool, optional):
251
+ Whether to use external visualization (planets on outer ring) for single-subject charts. Defaults to False.
243
252
  transparent_background (bool, optional):
244
253
  Whether to use a transparent background instead of the theme color. Defaults to False.
254
+ custom_title (str or None, optional):
255
+ Custom title for the chart. If None, the default title will be used based on chart type. Defaults to None.
245
256
  """
246
257
  # --------------------
247
258
  # COMMON INITIALIZATION
248
259
  # --------------------
249
- home_directory = Path.home()
250
260
  self.new_settings_file = new_settings_file
251
261
  self.chart_language = chart_language
252
- self.active_aspects = active_aspects
253
- self.chart_type = chart_type
254
262
  self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
255
263
  self.transparent_background = transparent_background
264
+ self.external_view = external_view
256
265
  self.chart_colors_settings = colors_settings
257
266
  self.planets_settings = celestial_points_settings
258
267
  self.aspects_settings = aspects_settings
259
-
260
- if not active_points:
261
- self.active_points = first_obj.active_points
262
- else:
263
- self.active_points = find_common_active_points(
264
- active_points,
265
- first_obj.active_points
266
- )
267
-
268
- if second_obj:
269
- self.active_points = find_common_active_points(
270
- self.active_points,
271
- second_obj.active_points
272
- )
273
-
274
- # Set output directory
275
- if new_output_directory:
276
- self.output_directory = Path(new_output_directory)
277
- else:
278
- self.output_directory = home_directory
268
+ self.custom_title = custom_title
269
+ self.auto_size = auto_size
270
+ self._padding = padding
271
+ self._vertical_offsets: dict[str, int] = self._BASE_VERTICAL_OFFSETS.copy()
272
+
273
+ # Extract data from ChartDataModel
274
+ self.chart_data = chart_data
275
+ self.chart_type = chart_data.chart_type
276
+ self.active_points = chart_data.active_points
277
+ self.active_aspects = chart_data.active_aspects
278
+
279
+ # Extract subjects based on chart type
280
+ if chart_data.chart_type in ["Natal", "Composite", "SingleReturnChart"]:
281
+ # SingleChartDataModel
282
+ self.first_obj = getattr(chart_data, 'subject')
283
+ self.second_obj = None
284
+
285
+ else: # DualChartDataModel for Transit, Synastry, DualReturnChart
286
+ self.first_obj = getattr(chart_data, 'first_subject')
287
+ self.second_obj = getattr(chart_data, 'second_subject')
279
288
 
280
289
  # Load settings
281
290
  self.parse_json_settings(new_settings_file)
282
291
 
283
- # Primary subject
284
- self.first_obj = first_obj
285
-
286
292
  # Default radius for all charts
287
293
  self.main_radius = 240
288
294
 
289
- # Configure available planets
295
+ # Configure available planets from chart data
290
296
  self.available_planets_setting = []
291
297
  for body in self.planets_settings:
292
298
  if body["name"] in self.active_points:
293
299
  body["is_active"] = True
294
- self.available_planets_setting.append(body)
300
+ self.available_planets_setting.append(body) # type: ignore[arg-type]
301
+
302
+ active_points_count = len(self.available_planets_setting)
303
+ if active_points_count > 24:
304
+ logging.warning(
305
+ "ChartDrawer detected %s active celestial points; rendering may look crowded beyond 24.",
306
+ active_points_count,
307
+ )
295
308
 
296
309
  # Set available celestial points
297
310
  available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
298
311
  self.available_kerykeion_celestial_points = []
299
312
  for body in available_celestial_points_names:
300
- self.available_kerykeion_celestial_points.append(self.first_obj.get(body))
313
+ if hasattr(self.first_obj, body):
314
+ self.available_kerykeion_celestial_points.append(self.first_obj.get(body)) # type: ignore[arg-type]
301
315
 
302
316
  # ------------------------
303
- # CHART TYPE SPECIFIC SETUP
317
+ # CHART TYPE SPECIFIC SETUP FROM CHART DATA
304
318
  # ------------------------
305
319
 
306
- if self.chart_type in ["Natal", "ExternalNatal"]:
307
- # --- NATAL / EXTERNAL NATAL CHART SETUP ---
308
-
309
- # Validate Subject
310
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
311
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
320
+ if self.chart_type == "Natal":
321
+ # --- NATAL CHART SETUP ---
312
322
 
313
- # Calculate aspects
314
- aspects_instance = AspectsFactory.single_chart_aspects(
315
- self.first_obj,
316
- active_points=self.active_points,
317
- active_aspects=active_aspects,
318
- )
319
- self.aspects_list = aspects_instance.relevant_aspects
323
+ # Extract aspects from pre-computed chart data
324
+ self.aspects_list = chart_data.aspects.relevant_aspects
320
325
 
321
326
  # Screen size
322
327
  self.height = self._DEFAULT_HEIGHT
323
328
  self.width = self._DEFAULT_NATAL_WIDTH
324
329
 
325
- # Location and coordinates
326
- self.location = self.first_obj.city
327
- self.geolat = self.first_obj.lat
328
- self.geolon = self.first_obj.lng
330
+ # Get location and coordinates
331
+ self.location, self.geolat, self.geolon = self._get_location_info()
329
332
 
330
- # Circle radii
331
- if self.chart_type == "ExternalNatal":
333
+ # Circle radii - depends on external_view
334
+ if self.external_view:
332
335
  self.first_circle_radius = 56
333
336
  self.second_circle_radius = 92
334
337
  self.third_circle_radius = 112
@@ -340,21 +343,15 @@ class KerykeionChartSVG:
340
343
  elif self.chart_type == "Composite":
341
344
  # --- COMPOSITE CHART SETUP ---
342
345
 
343
- # Validate Subject
344
- if not isinstance(self.first_obj, CompositeSubjectModel):
345
- raise KerykeionException("First object must be a CompositeSubjectModel instance.")
346
-
347
- # Calculate aspects
348
- self.aspects_list = AspectsFactory.single_chart_aspects(self.first_obj, active_points=self.active_points).relevant_aspects
346
+ # Extract aspects from pre-computed chart data
347
+ self.aspects_list = chart_data.aspects.relevant_aspects
349
348
 
350
349
  # Screen size
351
350
  self.height = self._DEFAULT_HEIGHT
352
351
  self.width = self._DEFAULT_NATAL_WIDTH
353
352
 
354
- # Location and coordinates (average of both subjects)
355
- self.location = ""
356
- self.geolat = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
357
- self.geolon = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
353
+ # Get location and coordinates
354
+ self.location, self.geolat, self.geolon = self._get_location_info()
358
355
 
359
356
  # Circle radii
360
357
  self.first_circle_radius = 0
@@ -364,25 +361,8 @@ class KerykeionChartSVG:
364
361
  elif self.chart_type == "Transit":
365
362
  # --- TRANSIT CHART SETUP ---
366
363
 
367
- # Validate Subjects
368
- if not second_obj:
369
- raise KerykeionException("Second object is required for Transit charts.")
370
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
371
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
372
- if not isinstance(second_obj, AstrologicalSubjectModel):
373
- raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
374
-
375
- # Secondary subject setup
376
- self.second_obj = second_obj
377
-
378
- # Calculate aspects (transit to natal)
379
- synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
380
- self.first_obj,
381
- self.second_obj,
382
- active_points=self.active_points,
383
- active_aspects=active_aspects,
384
- )
385
- self.aspects_list = synastry_aspects_instance.relevant_aspects
364
+ # Extract aspects from pre-computed chart data
365
+ self.aspects_list = chart_data.aspects.relevant_aspects
386
366
 
387
367
  # Secondary subject available points
388
368
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -394,11 +374,8 @@ class KerykeionChartSVG:
394
374
  else:
395
375
  self.width = self._DEFAULT_FULL_WIDTH
396
376
 
397
- # Location and coordinates (from transit subject)
398
- self.location = self.second_obj.city
399
- self.geolat = self.second_obj.lat
400
- self.geolon = self.second_obj.lng
401
- self.t_name = self.language_settings["transit_name"]
377
+ # Get location and coordinates
378
+ self.location, self.geolat, self.geolon = self._get_location_info()
402
379
 
403
380
  # Circle radii
404
381
  self.first_circle_radius = 0
@@ -408,25 +385,8 @@ class KerykeionChartSVG:
408
385
  elif self.chart_type == "Synastry":
409
386
  # --- SYNASTRY CHART SETUP ---
410
387
 
411
- # Validate Subjects
412
- if not second_obj:
413
- raise KerykeionException("Second object is required for Synastry charts.")
414
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
415
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
416
- if not isinstance(second_obj, AstrologicalSubjectModel):
417
- raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
418
-
419
- # Secondary subject setup
420
- self.second_obj = second_obj
421
-
422
- # Calculate aspects (natal to partner)
423
- synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
424
- self.first_obj,
425
- self.second_obj,
426
- active_points=self.active_points,
427
- active_aspects=active_aspects,
428
- )
429
- self.aspects_list = synastry_aspects_instance.relevant_aspects
388
+ # Extract aspects from pre-computed chart data
389
+ self.aspects_list = chart_data.aspects.relevant_aspects
430
390
 
431
391
  # Secondary subject available points
432
392
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -435,38 +395,19 @@ class KerykeionChartSVG:
435
395
  self.height = self._DEFAULT_HEIGHT
436
396
  self.width = self._DEFAULT_FULL_WIDTH
437
397
 
438
- # Location and coordinates (from primary subject)
439
- self.location = self.first_obj.city
440
- self.geolat = self.first_obj.lat
441
- self.geolon = self.first_obj.lng
398
+ # Get location and coordinates
399
+ self.location, self.geolat, self.geolon = self._get_location_info()
442
400
 
443
401
  # Circle radii
444
402
  self.first_circle_radius = 0
445
403
  self.second_circle_radius = 36
446
404
  self.third_circle_radius = 120
447
405
 
448
- elif self.chart_type == "Return":
406
+ elif self.chart_type == "DualReturnChart":
449
407
  # --- RETURN CHART SETUP ---
450
408
 
451
- # Validate Subjects
452
- if not second_obj:
453
- raise KerykeionException("Second object is required for Return charts.")
454
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
455
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
456
- if not isinstance(second_obj, PlanetReturnModel):
457
- raise KerykeionException("Second object must be a PlanetReturnModel instance.")
458
-
459
- # Secondary subject setup
460
- self.second_obj = second_obj
461
-
462
- # Calculate aspects (natal to return)
463
- synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
464
- self.first_obj,
465
- self.second_obj,
466
- active_points=self.active_points,
467
- active_aspects=active_aspects,
468
- )
469
- self.aspects_list = synastry_aspects_instance.relevant_aspects
409
+ # Extract aspects from pre-computed chart data
410
+ self.aspects_list = chart_data.aspects.relevant_aspects
470
411
 
471
412
  # Secondary subject available points
472
413
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -475,93 +416,45 @@ class KerykeionChartSVG:
475
416
  self.height = self._DEFAULT_HEIGHT
476
417
  self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
477
418
 
478
- # Location and coordinates (from natal subject)
479
- self.location = self.first_obj.city
480
- self.geolat = self.first_obj.lat
481
- self.geolon = self.first_obj.lng
419
+ # Get location and coordinates
420
+ self.location, self.geolat, self.geolon = self._get_location_info()
482
421
 
483
422
  # Circle radii
484
423
  self.first_circle_radius = 0
485
424
  self.second_circle_radius = 36
486
425
  self.third_circle_radius = 120
487
426
 
488
- elif self.chart_type == "SingleWheelReturn":
489
- # --- NATAL / EXTERNAL NATAL CHART SETUP ---
427
+ elif self.chart_type == "SingleReturnChart":
428
+ # --- SINGLE WHEEL RETURN CHART SETUP ---
490
429
 
491
- # Validate Subject
492
- if not isinstance(self.first_obj, PlanetReturnModel):
493
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
494
-
495
- # Calculate aspects
496
- aspects_instance = AspectsFactory.single_chart_aspects(
497
- self.first_obj,
498
- active_points=self.active_points,
499
- active_aspects=active_aspects,
500
- )
501
- self.aspects_list = aspects_instance.relevant_aspects
430
+ # Extract aspects from pre-computed chart data
431
+ self.aspects_list = chart_data.aspects.relevant_aspects
502
432
 
503
433
  # Screen size
504
434
  self.height = self._DEFAULT_HEIGHT
505
435
  self.width = self._DEFAULT_NATAL_WIDTH
506
436
 
507
- # Location and coordinates
508
- self.location = self.first_obj.city
509
- self.geolat = self.first_obj.lat
510
- self.geolon = self.first_obj.lng
437
+ # Get location and coordinates
438
+ self.location, self.geolat, self.geolon = self._get_location_info()
511
439
 
512
440
  # Circle radii
513
- if self.chart_type == "ExternalNatal":
514
- self.first_circle_radius = 56
515
- self.second_circle_radius = 92
516
- self.third_circle_radius = 112
517
- else:
518
- self.first_circle_radius = 0
519
- self.second_circle_radius = 36
520
- self.third_circle_radius = 120
441
+ self.first_circle_radius = 0
442
+ self.second_circle_radius = 36
443
+ self.third_circle_radius = 120
521
444
 
522
445
  # --------------------
523
- # FINAL COMMON SETUP
446
+ # FINAL COMMON SETUP FROM CHART DATA
524
447
  # --------------------
525
448
 
526
- # Calculate element points
527
- celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
528
- if self.chart_type == "Synastry":
529
- element_totals = calculate_synastry_element_points(
530
- self.available_planets_setting,
531
- celestial_points_names,
532
- self.first_obj,
533
- self.second_obj,
534
- )
535
- else:
536
- element_totals = calculate_element_points(
537
- self.available_planets_setting,
538
- celestial_points_names,
539
- self.first_obj,
540
- )
449
+ # Extract pre-computed element and quality distributions
450
+ self.fire = chart_data.element_distribution.fire
451
+ self.earth = chart_data.element_distribution.earth
452
+ self.air = chart_data.element_distribution.air
453
+ self.water = chart_data.element_distribution.water
541
454
 
542
- self.fire = element_totals["fire"]
543
- self.earth = element_totals["earth"]
544
- self.air = element_totals["air"]
545
- self.water = element_totals["water"]
546
-
547
- # Calculate qualities points
548
- if self.chart_type == "Synastry":
549
- qualities_totals = calculate_synastry_quality_points(
550
- self.available_planets_setting,
551
- celestial_points_names,
552
- self.first_obj,
553
- self.second_obj,
554
- )
555
- else:
556
- qualities_totals = calculate_quality_points(
557
- self.available_planets_setting,
558
- celestial_points_names,
559
- self.first_obj,
560
- )
561
-
562
- self.cardinal = qualities_totals["cardinal"]
563
- self.fixed = qualities_totals["fixed"]
564
- self.mutable = qualities_totals["mutable"]
455
+ self.cardinal = chart_data.quality_distribution.cardinal
456
+ self.fixed = chart_data.quality_distribution.fixed
457
+ self.mutable = chart_data.quality_distribution.mutable
565
458
 
566
459
  # Set up theme
567
460
  if theme not in get_args(KerykeionChartTheme) and theme is not None:
@@ -569,6 +462,215 @@ class KerykeionChartSVG:
569
462
 
570
463
  self.set_up_theme(theme)
571
464
 
465
+ # Optionally expand width dynamically to fit content
466
+ if self.auto_size:
467
+ try:
468
+ required_width = self._estimate_required_width_full()
469
+ if required_width > self.width:
470
+ self.width = required_width
471
+ except Exception as e:
472
+ # Keep default on any unexpected issue; do not break rendering
473
+ logging.debug(f"Auto-size width calculation failed: {e}")
474
+
475
+ self._apply_dynamic_height_adjustment()
476
+
477
+ def _count_active_planets(self) -> int:
478
+ """Return number of active celestial points in the current chart."""
479
+ return len([p for p in self.available_planets_setting if p.get("is_active")])
480
+
481
+ def _apply_dynamic_height_adjustment(self) -> None:
482
+ """Adjust chart height and vertical offsets based on active points."""
483
+ active_points_count = self._count_active_planets()
484
+
485
+ offsets = self._BASE_VERTICAL_OFFSETS.copy()
486
+
487
+ minimum_height = self._DEFAULT_HEIGHT
488
+ if active_points_count <= 20:
489
+ self.height = max(self.height, minimum_height)
490
+ self._vertical_offsets = offsets
491
+ return
492
+
493
+ extra_points = active_points_count - 20
494
+ extra_height = extra_points * self._ROW_HEIGHT
495
+
496
+ self.height = max(self.height, minimum_height + extra_height)
497
+
498
+ delta_height = max(self.height - minimum_height, 0)
499
+
500
+ # Anchor wheel, aspect grid/list, and lunar phase to the bottom
501
+ offsets["wheel"] += delta_height
502
+ offsets["aspect_grid"] += delta_height
503
+ offsets["aspect_list"] += delta_height
504
+ offsets["lunar_phase"] += delta_height
505
+ offsets["bottom_left"] += delta_height
506
+
507
+ # Smooth top offsets to keep breathing room near the title and grids
508
+ shift = min(extra_points * self._TOP_SHIFT_FACTOR, self._MAX_TOP_SHIFT)
509
+ top_shift = shift // 2
510
+
511
+ offsets["grid"] += shift
512
+ offsets["title"] += top_shift
513
+ offsets["elements"] += top_shift
514
+ offsets["qualities"] += top_shift
515
+
516
+ self._vertical_offsets = offsets
517
+
518
+ def _dynamic_viewbox(self) -> str:
519
+ """Return the viewBox string based on current width/height."""
520
+ return f"0 0 {int(self.width)} {int(self.height)}"
521
+
522
+ def _wheel_only_viewbox(self, margin: int = 20) -> str:
523
+ """Return a tight viewBox for the wheel-only template.
524
+
525
+ The wheel is drawn inside a group translated by (100, 50) and has
526
+ diameter 2 * main_radius. We add a small margin around it.
527
+ """
528
+ left = 100 - margin
529
+ top = 50 - margin
530
+ width = (2 * self.main_radius) + (2 * margin)
531
+ height = (2 * self.main_radius) + (2 * margin)
532
+ return f"{left} {top} {width} {height}"
533
+
534
+ def _grid_only_viewbox(self, margin: int = 10) -> str:
535
+ """Compute a tight viewBox for the Aspect Grid Only SVG.
536
+
537
+ The grid is rendered using fixed origins and box size:
538
+ - For Transit/Synastry/DualReturn charts, `draw_transit_aspect_grid`
539
+ uses `x_indent=50`, `y_indent=250`, `box_size=14` and draws:
540
+ • a header row to the right of `x_indent`
541
+ • a left header column at `x_indent - box_size`
542
+ • an NxN grid of cells above `y_indent`
543
+
544
+ - For Natal/Composite/SingleReturn charts, `draw_aspect_grid` uses
545
+ `x_start=50`, `y_start=250`, `box_size=14` and draws a triangular grid
546
+ that extends to the right (x) and upwards (y).
547
+
548
+ This function mirrors that geometry to return a snug viewBox around the
549
+ content, with a small configurable `margin`.
550
+
551
+ Args:
552
+ margin: Extra pixels to add on each side of the computed bounds.
553
+
554
+ Returns:
555
+ A string "minX minY width height" suitable for the SVG `viewBox`.
556
+ """
557
+ # Must match defaults used in the renderers
558
+ x0 = 50
559
+ y0 = 250
560
+ box = 14
561
+
562
+ n = max(len([p for p in self.available_planets_setting if p.get("is_active")]), 1)
563
+
564
+ if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
565
+ # Full N×N grid
566
+ left = (x0 - box) - margin
567
+ top = (y0 - box * n) - margin
568
+ right = (x0 + box * n) + margin
569
+ bottom = (y0 + box) + margin
570
+ else:
571
+ # Triangular grid (no extra left column)
572
+ left = x0 - margin
573
+ top = (y0 - box * n) - margin
574
+ right = (x0 + box * n) + margin
575
+ bottom = (y0 + box) + margin
576
+
577
+ width = max(1, int(right - left))
578
+ height = max(1, int(bottom - top))
579
+
580
+ return f"{int(left)} {int(top)} {width} {height}"
581
+
582
+ def _estimate_required_width_full(self) -> int:
583
+ """Estimate minimal width to contain all rendered groups for the full chart.
584
+
585
+ The calculation is heuristic and mirrors the default x positions used in
586
+ the SVG templates and drawing utilities. We keep a conservative padding.
587
+ """
588
+ # Wheel footprint (translate(100,50) + diameter of 2*radius)
589
+ wheel_right = 100 + (2 * self.main_radius)
590
+ extents = [wheel_right]
591
+
592
+ n_active = max(self._count_active_planets(), 1)
593
+
594
+ # Common grids present on many chart types
595
+ main_planet_grid_right = 645 + 80
596
+ main_houses_grid_right = 750 + 120
597
+ extents.extend([main_planet_grid_right, main_houses_grid_right])
598
+
599
+ if self.chart_type in ("Natal", "Composite", "SingleReturnChart"):
600
+ # Triangular aspect grid at x_start=540, width ~ 14 * n_active
601
+ aspect_grid_right = 560 + 14 * n_active
602
+ extents.append(aspect_grid_right)
603
+
604
+ if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
605
+ # Double-chart aspects placement
606
+ if self.double_chart_aspect_grid_type == "list":
607
+ # Columnar list placed at translate(565,273), ~100-110px per column, 14 aspects per column
608
+ aspects_per_column = 14
609
+ total_aspects = len(self.aspects_list) if hasattr(self, "aspects_list") else 0
610
+ columns = max((total_aspects + aspects_per_column - 1) // aspects_per_column, 1)
611
+ # Respect the max columns cap used in rendering: DualReturn=7, others=6
612
+ max_cols_cap = 7
613
+ columns = min(columns, max_cols_cap)
614
+ aspect_list_right = 565 + (columns * 110)
615
+ extents.append(aspect_list_right)
616
+ else:
617
+ # Grid table placed with x_indent ~550, width ~ 14px per cell across n_active+1
618
+ aspect_grid_table_right = 550 + (14 * (n_active + 1))
619
+ extents.append(aspect_grid_table_right)
620
+
621
+ # Secondary grids
622
+ secondary_planet_grid_right = 910 + 80
623
+ extents.append(secondary_planet_grid_right)
624
+
625
+ if self.chart_type == "Synastry":
626
+ # Secondary houses grid default x ~ 1015
627
+ secondary_houses_grid_right = 1015 + 120
628
+ extents.append(secondary_houses_grid_right)
629
+
630
+ if self.chart_type == "Transit":
631
+ # House comparison grid at x ~ 1030
632
+ house_comparison_grid_right = 1030 + 180
633
+ extents.append(house_comparison_grid_right)
634
+
635
+ if self.chart_type == "DualReturnChart":
636
+ # House comparison grid at x ~ 1030
637
+ house_comparison_grid_right = 1030 + 320
638
+ extents.append(house_comparison_grid_right)
639
+
640
+ # Conservative safety padding
641
+ return int(max(extents) + self._padding)
642
+
643
+ def _get_location_info(self) -> tuple[str, float, float]:
644
+ """
645
+ Determine location information based on chart type and subjects.
646
+
647
+ Returns:
648
+ tuple: (location_name, latitude, longitude)
649
+ """
650
+ if self.chart_type == "Composite":
651
+ # For composite charts, use average location of the two composite subjects
652
+ if isinstance(self.first_obj, CompositeSubjectModel):
653
+ location_name = ""
654
+ latitude = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
655
+ longitude = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
656
+ else:
657
+ # Fallback to first subject location
658
+ location_name = self.first_obj.city or "Unknown"
659
+ latitude = self.first_obj.lat or 0.0
660
+ longitude = self.first_obj.lng or 0.0
661
+ elif self.chart_type in ["Transit", "DualReturnChart"] and self.second_obj:
662
+ # Use location from the second subject (transit/return)
663
+ location_name = self.second_obj.city or "Unknown"
664
+ latitude = self.second_obj.lat or 0.0
665
+ longitude = self.second_obj.lng or 0.0
666
+ else:
667
+ # Use location from the first subject
668
+ location_name = self.first_obj.city or "Unknown"
669
+ latitude = self.first_obj.lat or 0.0
670
+ longitude = self.first_obj.lng or 0.0
671
+
672
+ return location_name, latitude, longitude
673
+
572
674
  def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
573
675
  """
574
676
  Load and apply a CSS theme for the chart visualization.
@@ -585,16 +687,6 @@ class KerykeionChartSVG:
585
687
  with open(theme_dir / f"{theme}.css", "r") as f:
586
688
  self.color_style_tag = f.read()
587
689
 
588
- def set_output_directory(self, dir_path: Path) -> None:
589
- """
590
- Set the directory where generated SVG files will be saved.
591
-
592
- Args:
593
- dir_path (Path): Target directory for SVG output.
594
- """
595
- self.output_directory = dir_path
596
- logging.info(f"Output directory set to: {self.output_directory}")
597
-
598
690
  def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
599
691
  """
600
692
  Load and parse chart configuration settings.
@@ -682,7 +774,89 @@ class KerykeionChartSVG:
682
774
  )
683
775
  return out
684
776
 
685
- def _create_template_dictionary(self) -> ChartTemplateDictionary:
777
+ def _truncate_name(self, name: str, max_length: int = 50) -> str:
778
+ """
779
+ Truncate a name if it's too long, preserving readability.
780
+
781
+ Args:
782
+ name (str): The name to truncate
783
+ max_length (int): Maximum allowed length
784
+
785
+ Returns:
786
+ str: Truncated name with ellipsis if needed
787
+ """
788
+ if len(name) <= max_length:
789
+ return name
790
+ return name[:max_length-1] + "…"
791
+
792
+ def _get_chart_title(self) -> str:
793
+ """
794
+ Generate the chart title based on chart type and custom title settings.
795
+
796
+ If a custom title is provided, it will be used. Otherwise, generates the
797
+ appropriate default title based on the chart type and subjects.
798
+
799
+ Returns:
800
+ str: The chart title to display (max ~40 characters).
801
+ """
802
+ # If custom title is provided, use it
803
+ if self.custom_title is not None:
804
+ return self.custom_title
805
+
806
+ # Generate default title based on chart type
807
+ if self.chart_type == "Natal":
808
+ natal_label = self.language_settings.get("birth_chart", "Natal")
809
+ truncated_name = self._truncate_name(self.first_obj.name)
810
+ return f'{truncated_name} - {natal_label}'
811
+
812
+ elif self.chart_type == "Composite":
813
+ composite_label = self.language_settings.get("composite_chart", "Composite")
814
+ and_word = self.language_settings.get("and_word", "&")
815
+ name1 = self._truncate_name(self.first_obj.first_subject.name) # type: ignore
816
+ name2 = self._truncate_name(self.first_obj.second_subject.name) # type: ignore
817
+ return f"{composite_label}: {name1} {and_word} {name2}"
818
+
819
+ elif self.chart_type == "Transit":
820
+ transit_label = self.language_settings.get("transits", "Transits")
821
+ from datetime import datetime
822
+ date_obj = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime) # type: ignore
823
+ date_str = date_obj.strftime("%d/%m/%y")
824
+ truncated_name = self._truncate_name(self.first_obj.name)
825
+ return f"{truncated_name} - {transit_label} {date_str}"
826
+
827
+ elif self.chart_type == "Synastry":
828
+ synastry_label = self.language_settings.get("synastry_chart", "Synastry")
829
+ and_word = self.language_settings.get("and_word", "&")
830
+ name1 = self._truncate_name(self.first_obj.name)
831
+ name2 = self._truncate_name(self.second_obj.name) # type: ignore
832
+ return f"{synastry_label}: {name1} {and_word} {name2}"
833
+
834
+ elif self.chart_type == "DualReturnChart":
835
+ from datetime import datetime
836
+ year = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime).year # type: ignore
837
+ truncated_name = self._truncate_name(self.first_obj.name)
838
+ if self.second_obj is not None and isinstance(self.second_obj, PlanetReturnModel) and self.second_obj.return_type == "Solar":
839
+ solar_label = self.language_settings.get("solar_return", "Solar")
840
+ return f"{truncated_name} - {solar_label} {year}"
841
+ else:
842
+ lunar_label = self.language_settings.get("lunar_return", "Lunar")
843
+ return f"{truncated_name} - {lunar_label} {year}"
844
+
845
+ elif self.chart_type == "SingleReturnChart":
846
+ from datetime import datetime
847
+ year = datetime.fromisoformat(self.first_obj.iso_formatted_local_datetime).year # type: ignore
848
+ truncated_name = self._truncate_name(self.first_obj.name)
849
+ if isinstance(self.first_obj, PlanetReturnModel) and self.first_obj.return_type == "Solar":
850
+ solar_label = self.language_settings.get("solar_return", "Solar")
851
+ return f"{truncated_name} - {solar_label} {year}"
852
+ else:
853
+ lunar_label = self.language_settings.get("lunar_return", "Lunar")
854
+ return f"{truncated_name} - {lunar_label} {year}"
855
+
856
+ # Fallback for unknown chart types
857
+ return self._truncate_name(self.first_obj.name)
858
+
859
+ def _create_template_dictionary(self) -> ChartTemplateModel:
686
860
  """
687
861
  Assemble chart data and rendering instructions into a template dictionary.
688
862
 
@@ -690,7 +864,7 @@ class KerykeionChartSVG:
690
864
  chart type and subjects.
691
865
 
692
866
  Returns:
693
- ChartTemplateDictionary: Populated structure of template variables.
867
+ ChartTemplateModel: Populated structure of template variables.
694
868
  """
695
869
  # Initialize template dictionary
696
870
  template_dict: dict = {}
@@ -704,9 +878,19 @@ class KerykeionChartSVG:
704
878
  template_dict["chart_height"] = self.height
705
879
  template_dict["chart_width"] = self.width
706
880
 
881
+ offsets = self._vertical_offsets
882
+ template_dict["full_wheel_translate_y"] = offsets["wheel"]
883
+ template_dict["houses_and_planets_translate_y"] = offsets["grid"]
884
+ template_dict["aspect_grid_translate_y"] = offsets["aspect_grid"]
885
+ template_dict["aspect_list_translate_y"] = offsets["aspect_list"]
886
+ template_dict["title_translate_y"] = offsets["title"]
887
+ template_dict["elements_translate_y"] = offsets["elements"]
888
+ template_dict["qualities_translate_y"] = offsets["qualities"]
889
+ template_dict["lunar_phase_translate_y"] = offsets["lunar_phase"]
890
+ template_dict["bottom_left_translate_y"] = offsets["bottom_left"]
891
+
707
892
  # Set paper colors
708
893
  template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
709
- template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"]
710
894
 
711
895
  # Set background color based on transparent_background setting
712
896
  if self.transparent_background:
@@ -714,7 +898,12 @@ class KerykeionChartSVG:
714
898
  else:
715
899
  template_dict["background_color"] = self.chart_colors_settings["paper_1"]
716
900
 
717
- # Set planet colors
901
+ # Set planet colors - initialize all possible colors first with defaults
902
+ default_color = "#000000" # Default black color for unused planets
903
+ for i in range(42): # Support all 42 celestial points (0-41)
904
+ template_dict[f"planets_color_{i}"] = default_color
905
+
906
+ # Override with actual colors from settings
718
907
  for planet in self.planets_settings:
719
908
  planet_id = planet["id"]
720
909
  template_dict[f"planets_color_{planet_id}"] = planet["color"]
@@ -732,10 +921,12 @@ class KerykeionChartSVG:
732
921
 
733
922
  # Calculate element percentages
734
923
  total_elements = self.fire + self.water + self.earth + self.air
735
- fire_percentage = int(round(100 * self.fire / total_elements))
736
- earth_percentage = int(round(100 * self.earth / total_elements))
737
- air_percentage = int(round(100 * self.air / total_elements))
738
- water_percentage = int(round(100 * self.water / total_elements))
924
+ element_values = {"fire": self.fire, "earth": self.earth, "air": self.air, "water": self.water}
925
+ element_percentages = distribute_percentages_to_100(element_values) if total_elements > 0 else {"fire": 0, "earth": 0, "air": 0, "water": 0}
926
+ fire_percentage = element_percentages["fire"]
927
+ earth_percentage = element_percentages["earth"]
928
+ air_percentage = element_percentages["air"]
929
+ water_percentage = element_percentages["water"]
739
930
 
740
931
  # Element Percentages
741
932
  template_dict["elements_string"] = f"{self.language_settings.get('elements', 'Elements')}:"
@@ -747,9 +938,11 @@ class KerykeionChartSVG:
747
938
 
748
939
  # Qualities Percentages
749
940
  total_qualities = self.cardinal + self.fixed + self.mutable
750
- cardinal_percentage = int(round(100 * self.cardinal / total_qualities))
751
- fixed_percentage = int(round(100 * self.fixed / total_qualities))
752
- mutable_percentage = int(round(100 * self.mutable / total_qualities))
941
+ quality_values = {"cardinal": self.cardinal, "fixed": self.fixed, "mutable": self.mutable}
942
+ quality_percentages = distribute_percentages_to_100(quality_values) if total_qualities > 0 else {"cardinal": 0, "fixed": 0, "mutable": 0}
943
+ cardinal_percentage = quality_percentages["cardinal"]
944
+ fixed_percentage = quality_percentages["fixed"]
945
+ mutable_percentage = quality_percentages["mutable"]
753
946
 
754
947
  template_dict["qualities_string"] = f"{self.language_settings.get('qualities', 'Qualities')}:"
755
948
  template_dict["cardinal_string"] = f"{self.language_settings.get('cardinal', 'Cardinal')} {cardinal_percentage}%"
@@ -759,13 +952,16 @@ class KerykeionChartSVG:
759
952
  # Get houses list for main subject
760
953
  first_subject_houses_list = get_houses_list(self.first_obj)
761
954
 
955
+ # Chart title
956
+ template_dict["stringTitle"] = self._get_chart_title()
957
+
762
958
  # ------------------------------- #
763
959
  # CHART TYPE SPECIFIC SETTINGS #
764
960
  # ------------------------------- #
765
961
 
766
- if self.chart_type in ["Natal", "ExternalNatal"]:
767
- # Set viewbox
768
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
962
+ if self.chart_type == "Natal":
963
+ # Set viewbox dynamically
964
+ template_dict["viewbox"] = self._dynamic_viewbox()
769
965
 
770
966
  # Rings and circles
771
967
  template_dict["transitRing"] = ""
@@ -810,9 +1006,6 @@ class KerykeionChartSVG:
810
1006
  )
811
1007
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
812
1008
 
813
- # Chart title
814
- template_dict["stringTitle"] = f'{self.first_obj.name} - {self.language_settings.get("birth_chart", "Birth Chart")}'
815
-
816
1009
  # Top left section
817
1010
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
818
1011
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -822,7 +1015,8 @@ class KerykeionChartSVG:
822
1015
  template_dict["top_left_2"] = f"{self.language_settings['latitude']}: {latitude_string}"
823
1016
  template_dict["top_left_3"] = f"{self.language_settings['longitude']}: {longitude_string}"
824
1017
  template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
825
- template_dict["top_left_5"] = f"{self.language_settings.get('day_of_week', 'Day of Week')}: {self.first_obj.day_of_week}" # type: ignore
1018
+ localized_weekday = self.language_settings.get('weekdays', {}).get(self.first_obj.day_of_week, self.first_obj.day_of_week) # type: ignore
1019
+ template_dict["top_left_5"] = f"{self.language_settings.get('day_of_week', 'Day of Week')}: {localized_weekday}" # type: ignore
826
1020
 
827
1021
  # Bottom left section
828
1022
  if self.first_obj.zodiac_type == "Tropic":
@@ -870,6 +1064,7 @@ class KerykeionChartSVG:
870
1064
  c1=self.first_circle_radius,
871
1065
  c3=self.third_circle_radius,
872
1066
  chart_type=self.chart_type,
1067
+ external_view=self.external_view,
873
1068
  )
874
1069
 
875
1070
  template_dict["makePlanets"] = draw_planets(
@@ -880,6 +1075,7 @@ class KerykeionChartSVG:
880
1075
  third_circle_radius=self.third_circle_radius,
881
1076
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
882
1077
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1078
+ external_view=self.external_view,
883
1079
  )
884
1080
 
885
1081
  template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
@@ -894,8 +1090,8 @@ class KerykeionChartSVG:
894
1090
  template_dict["makeHouseComparisonGrid"] = ""
895
1091
 
896
1092
  elif self.chart_type == "Composite":
897
- # Set viewbox
898
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1093
+ # Set viewbox dynamically
1094
+ template_dict["viewbox"] = self._dynamic_viewbox()
899
1095
 
900
1096
  # Rings and circles
901
1097
  template_dict["transitRing"] = ""
@@ -940,9 +1136,6 @@ class KerykeionChartSVG:
940
1136
  )
941
1137
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
942
1138
 
943
- # Chart title
944
- template_dict["stringTitle"] = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
945
-
946
1139
  # Top left section
947
1140
  # First subject
948
1141
  latitude = convert_latitude_coordinate_to_string(
@@ -1014,6 +1207,7 @@ class KerykeionChartSVG:
1014
1207
  c1=self.first_circle_radius,
1015
1208
  c3=self.third_circle_radius,
1016
1209
  chart_type=self.chart_type,
1210
+ external_view=self.external_view,
1017
1211
  )
1018
1212
 
1019
1213
  template_dict["makePlanets"] = draw_planets(
@@ -1024,6 +1218,7 @@ class KerykeionChartSVG:
1024
1218
  third_circle_radius=self.third_circle_radius,
1025
1219
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1026
1220
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1221
+ external_view=self.external_view,
1027
1222
  )
1028
1223
 
1029
1224
  subject_name = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
@@ -1054,11 +1249,8 @@ class KerykeionChartSVG:
1054
1249
  template_dict["fixed_string"] = ""
1055
1250
  template_dict["mutable_string"] = ""
1056
1251
 
1057
- # Set viewbox
1058
- if self.double_chart_aspect_grid_type == "table":
1059
- template_dict["viewbox"] = self._TRANSIT_CHART_WITH_TABLE_VIWBOX
1060
- else:
1061
- template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1252
+ # Set viewbox dynamically
1253
+ template_dict["viewbox"] = self._dynamic_viewbox()
1062
1254
 
1063
1255
  # Get houses list for secondary subject
1064
1256
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1098,22 +1290,19 @@ class KerykeionChartSVG:
1098
1290
  if self.double_chart_aspect_grid_type == "list":
1099
1291
  title = f'{self.first_obj.name} - {self.language_settings.get("transit_aspects", "Transit Aspects")}'
1100
1292
  template_dict["makeAspectGrid"] = ""
1101
- template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings)
1293
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings) # type: ignore[arg-type] # type: ignore[arg-type]
1102
1294
  else:
1103
1295
  template_dict["makeAspectGrid"] = ""
1104
1296
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1105
1297
  self.chart_colors_settings["paper_0"],
1106
1298
  self.available_planets_setting,
1107
1299
  self.aspects_list,
1108
- 550,
1109
- 450,
1300
+ 600,
1301
+ 520,
1110
1302
  )
1111
1303
 
1112
1304
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1113
1305
 
1114
- # Chart title
1115
- template_dict["stringTitle"] = f"{self.language_settings['transits']} {format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime)}" # type: ignore
1116
-
1117
1306
  # Top left section
1118
1307
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1119
1308
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -1176,6 +1365,7 @@ class KerykeionChartSVG:
1176
1365
  c1=self.first_circle_radius,
1177
1366
  c3=self.third_circle_radius,
1178
1367
  chart_type=self.chart_type,
1368
+ external_view=self.external_view,
1179
1369
  second_subject_houses_list=second_subject_houses_list,
1180
1370
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1181
1371
  )
@@ -1189,6 +1379,7 @@ class KerykeionChartSVG:
1189
1379
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1190
1380
  chart_type=self.chart_type,
1191
1381
  third_circle_radius=self.third_circle_radius,
1382
+ external_view=self.external_view,
1192
1383
  )
1193
1384
 
1194
1385
  # Planet grids
@@ -1214,8 +1405,8 @@ class KerykeionChartSVG:
1214
1405
 
1215
1406
  # House comparison grid
1216
1407
  house_comparison_factory = HouseComparisonFactory(
1217
- first_subject=self.first_obj,
1218
- second_subject=self.second_obj,
1408
+ first_subject=self.first_obj, # type: ignore[arg-type]
1409
+ second_subject=self.second_obj, # type: ignore[arg-type]
1219
1410
  active_points=self.active_points,
1220
1411
  )
1221
1412
  house_comparison = house_comparison_factory.get_house_comparison()
@@ -1228,12 +1419,12 @@ class KerykeionChartSVG:
1228
1419
  house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1229
1420
  return_point_label=self.language_settings.get("transit_point", "Transit Point"),
1230
1421
  natal_house_label=self.language_settings.get("house_position", "Natal House"),
1231
- x_position=930,
1422
+ x_position=980,
1232
1423
  )
1233
1424
 
1234
1425
  elif self.chart_type == "Synastry":
1235
- # Set viewbox
1236
- template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1426
+ # Set viewbox dynamically
1427
+ template_dict["viewbox"] = self._dynamic_viewbox()
1237
1428
 
1238
1429
  # Get houses list for secondary subject
1239
1430
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1273,10 +1464,10 @@ class KerykeionChartSVG:
1273
1464
  if self.double_chart_aspect_grid_type == "list":
1274
1465
  template_dict["makeAspectGrid"] = ""
1275
1466
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1276
- f"{self.first_obj.name} - {self.second_obj.name} {self.language_settings.get('synastry_aspects', 'Synastry Aspects')}", # type: ignore
1467
+ f"{self.first_obj.name} - {self.second_obj.name} {self.language_settings.get('synastry_aspects', 'Synastry Aspects')}", # type: ignore[union-attr]
1277
1468
  self.aspects_list,
1278
- self.planets_settings,
1279
- self.aspects_settings
1469
+ self.planets_settings, # type: ignore[arg-type]
1470
+ self.aspects_settings # type: ignore[arg-type]
1280
1471
  )
1281
1472
  else:
1282
1473
  template_dict["makeAspectGrid"] = ""
@@ -1290,9 +1481,6 @@ class KerykeionChartSVG:
1290
1481
 
1291
1482
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1292
1483
 
1293
- # Chart title
1294
- template_dict["stringTitle"] = f"{self.first_obj.name} {self.language_settings['and_word']} {self.second_obj.name}" # type: ignore
1295
-
1296
1484
  # Top left section
1297
1485
  template_dict["top_left_0"] = f"{self.first_obj.name}:"
1298
1486
  template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore
@@ -1343,6 +1531,7 @@ class KerykeionChartSVG:
1343
1531
  c1=self.first_circle_radius,
1344
1532
  c3=self.third_circle_radius,
1345
1533
  chart_type=self.chart_type,
1534
+ external_view=self.external_view,
1346
1535
  second_subject_houses_list=second_subject_houses_list,
1347
1536
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1348
1537
  )
@@ -1356,6 +1545,7 @@ class KerykeionChartSVG:
1356
1545
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1357
1546
  chart_type=self.chart_type,
1358
1547
  third_circle_radius=self.third_circle_radius,
1548
+ external_view=self.external_view,
1359
1549
  )
1360
1550
 
1361
1551
  # Planet grid
@@ -1377,9 +1567,9 @@ class KerykeionChartSVG:
1377
1567
  )
1378
1568
  template_dict["makeHouseComparisonGrid"] = ""
1379
1569
 
1380
- elif self.chart_type == "Return":
1381
- # Set viewbox
1382
- template_dict["viewbox"] = self._ULTRA_WIDE_CHART_VIEWBOX
1570
+ elif self.chart_type == "DualReturnChart":
1571
+ # Set viewbox dynamically
1572
+ template_dict["viewbox"] = self._dynamic_viewbox()
1383
1573
 
1384
1574
  # Get houses list for secondary subject
1385
1575
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1419,7 +1609,7 @@ class KerykeionChartSVG:
1419
1609
  if self.double_chart_aspect_grid_type == "list":
1420
1610
  title = self.language_settings.get("return_aspects", "Natal to Return Aspects")
1421
1611
  template_dict["makeAspectGrid"] = ""
1422
- template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings, max_columns=7)
1612
+ template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings, max_columns=7) # type: ignore[arg-type] # type: ignore[arg-type]
1423
1613
  else:
1424
1614
  template_dict["makeAspectGrid"] = ""
1425
1615
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
@@ -1432,12 +1622,6 @@ class KerykeionChartSVG:
1432
1622
 
1433
1623
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1434
1624
 
1435
- # Chart title
1436
- if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1437
- template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('solar_return', 'Solar Return')}"
1438
- else:
1439
- template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('lunar_return', 'Lunar Return')}"
1440
-
1441
1625
 
1442
1626
  # Top left section
1443
1627
  # Subject
@@ -1509,6 +1693,7 @@ class KerykeionChartSVG:
1509
1693
  c1=self.first_circle_radius,
1510
1694
  c3=self.third_circle_radius,
1511
1695
  chart_type=self.chart_type,
1696
+ external_view=self.external_view,
1512
1697
  second_subject_houses_list=second_subject_houses_list,
1513
1698
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1514
1699
  )
@@ -1522,6 +1707,7 @@ class KerykeionChartSVG:
1522
1707
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1523
1708
  chart_type=self.chart_type,
1524
1709
  third_circle_radius=self.third_circle_radius,
1710
+ external_view=self.external_view,
1525
1711
  )
1526
1712
 
1527
1713
  # Planet grid
@@ -1549,8 +1735,8 @@ class KerykeionChartSVG:
1549
1735
  )
1550
1736
 
1551
1737
  house_comparison_factory = HouseComparisonFactory(
1552
- first_subject=self.first_obj,
1553
- second_subject=self.second_obj,
1738
+ first_subject=self.first_obj, # type: ignore[arg-type]
1739
+ second_subject=self.second_obj, # type: ignore[arg-type]
1554
1740
  active_points=self.active_points,
1555
1741
  )
1556
1742
  house_comparison = house_comparison_factory.get_house_comparison()
@@ -1562,13 +1748,13 @@ class KerykeionChartSVG:
1562
1748
  points_owner_subject_number=2, # The second subject is the Solar Return
1563
1749
  house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1564
1750
  return_point_label=self.language_settings.get("return_point", "Return Point"),
1565
- return_label=self.language_settings.get("Return", "Return"),
1751
+ return_label=self.language_settings.get("Return", "DualReturnChart"),
1566
1752
  radix_label=self.language_settings.get("Natal", "Natal"),
1567
1753
  )
1568
1754
 
1569
- elif self.chart_type == "SingleWheelReturn":
1570
- # Set viewbox
1571
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1755
+ elif self.chart_type == "SingleReturnChart":
1756
+ # Set viewbox dynamically
1757
+ template_dict["viewbox"] = self._dynamic_viewbox()
1572
1758
 
1573
1759
  # Rings and circles
1574
1760
  template_dict["transitRing"] = ""
@@ -1613,9 +1799,6 @@ class KerykeionChartSVG:
1613
1799
  )
1614
1800
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1615
1801
 
1616
- # Chart title
1617
- template_dict["stringTitle"] = self.first_obj.name
1618
-
1619
1802
  # Top left section
1620
1803
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1621
1804
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -1677,6 +1860,7 @@ class KerykeionChartSVG:
1677
1860
  c1=self.first_circle_radius,
1678
1861
  c3=self.third_circle_radius,
1679
1862
  chart_type=self.chart_type,
1863
+ external_view=self.external_view,
1680
1864
  )
1681
1865
 
1682
1866
  template_dict["makePlanets"] = draw_planets(
@@ -1687,6 +1871,7 @@ class KerykeionChartSVG:
1687
1871
  third_circle_radius=self.third_circle_radius,
1688
1872
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1689
1873
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1874
+ external_view=self.external_view,
1690
1875
  )
1691
1876
 
1692
1877
  template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
@@ -1700,9 +1885,9 @@ class KerykeionChartSVG:
1700
1885
  template_dict["makeSecondaryPlanetGrid"] = ""
1701
1886
  template_dict["makeHouseComparisonGrid"] = ""
1702
1887
 
1703
- return ChartTemplateDictionary(**template_dict)
1888
+ return ChartTemplateModel(**template_dict)
1704
1889
 
1705
- def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str:
1890
+ def generate_svg_string(self, minify: bool = False, remove_css_variables=False) -> str:
1706
1891
  """
1707
1892
  Render the full chart SVG as a string.
1708
1893
 
@@ -1723,11 +1908,11 @@ class KerykeionChartSVG:
1723
1908
 
1724
1909
  # read template
1725
1910
  with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
1726
- template = Template(f.read()).substitute(td)
1911
+ template = Template(f.read()).substitute(td.model_dump())
1727
1912
 
1728
1913
  # return filename
1729
1914
 
1730
- logging.debug(f"Template dictionary keys: {td.keys()}")
1915
+ logging.debug(f"Template dictionary has {len(td.model_dump())} fields")
1731
1916
 
1732
1917
  self._create_template_dictionary()
1733
1918
 
@@ -1742,36 +1927,50 @@ class KerykeionChartSVG:
1742
1927
 
1743
1928
  return template
1744
1929
 
1745
- def makeSVG(self, minify: bool = False, remove_css_variables=False):
1930
+ def save_svg(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
1746
1931
  """
1747
1932
  Generate and save the full chart SVG to disk.
1748
1933
 
1749
- Calls makeTemplate to render the SVG, then writes a file named
1750
- "{subject.name} - {chart_type} Chart.svg" in the output directory.
1934
+ Calls generate_svg_string to render the SVG, then writes a file named
1935
+ "{subject.name} - {chart_type} Chart.svg" in the specified output directory.
1751
1936
 
1752
1937
  Args:
1753
- minify (bool): Pass-through to makeTemplate for compact output.
1754
- remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
1938
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
1939
+ If None, defaults to the user's home directory.
1940
+ filename (str or None): Custom filename for the SVG file (without extension).
1941
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart".
1942
+ minify (bool): Pass-through to generate_svg_string for compact output.
1943
+ remove_css_variables (bool): Pass-through to generate_svg_string to embed CSS variables.
1755
1944
 
1756
1945
  Returns:
1757
1946
  None
1758
1947
  """
1759
1948
 
1760
- self.template = self.makeTemplate(minify, remove_css_variables)
1949
+ self.template = self.generate_svg_string(minify, remove_css_variables)
1950
+
1951
+ # Convert output_path to Path object, default to home directory
1952
+ output_directory = Path(output_path) if output_path is not None else Path.home()
1761
1953
 
1762
- if self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar":
1763
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Lunar Return.svg"
1764
- elif self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1765
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Solar Return.svg"
1954
+ # Determine filename
1955
+ if filename is not None:
1956
+ chartname = output_directory / f"{filename}.svg"
1766
1957
  else:
1767
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart.svg"
1958
+ # Use default filename pattern
1959
+ chart_type_for_filename = self.chart_type
1960
+
1961
+ 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":
1962
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Lunar Return.svg"
1963
+ 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":
1964
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Solar Return.svg"
1965
+ else:
1966
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart.svg"
1768
1967
 
1769
1968
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1770
1969
  output_file.write(self.template)
1771
1970
 
1772
1971
  print(f"SVG Generated Correctly in: {chartname}")
1773
1972
 
1774
- def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1973
+ def generate_wheel_only_svg_string(self, minify: bool = False, remove_css_variables=False):
1775
1974
  """
1776
1975
  Render the wheel-only chart SVG as a string.
1777
1976
 
@@ -1795,7 +1994,9 @@ class KerykeionChartSVG:
1795
1994
  template = f.read()
1796
1995
 
1797
1996
  template_dict = self._create_template_dictionary()
1798
- template = Template(template).substitute(template_dict)
1997
+ # Use a compact viewBox specific for the wheel-only rendering
1998
+ wheel_viewbox = self._wheel_only_viewbox()
1999
+ template = Template(template).substitute({**template_dict.model_dump(), "viewbox": wheel_viewbox})
1799
2000
 
1800
2001
  if remove_css_variables:
1801
2002
  template = inline_css_variables_in_svg(template)
@@ -1808,30 +2009,44 @@ class KerykeionChartSVG:
1808
2009
 
1809
2010
  return template
1810
2011
 
1811
- def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False):
2012
+ 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):
1812
2013
  """
1813
2014
  Generate and save wheel-only chart SVG to disk.
1814
2015
 
1815
- Calls makeWheelOnlyTemplate and writes a file named
1816
- "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
2016
+ Calls generate_wheel_only_svg_string and writes a file named
2017
+ "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the specified output directory.
1817
2018
 
1818
2019
  Args:
1819
- minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
1820
- remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
2020
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
2021
+ If None, defaults to the user's home directory.
2022
+ filename (str or None): Custom filename for the SVG file (without extension).
2023
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Wheel Only".
2024
+ minify (bool): Pass-through to generate_wheel_only_svg_string for compact output.
2025
+ remove_css_variables (bool): Pass-through to generate_wheel_only_svg_string to embed CSS variables.
1821
2026
 
1822
2027
  Returns:
1823
2028
  None
1824
2029
  """
1825
2030
 
1826
- template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
1827
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Wheel Only.svg"
2031
+ template = self.generate_wheel_only_svg_string(minify, remove_css_variables)
2032
+
2033
+ # Convert output_path to Path object, default to home directory
2034
+ output_directory = Path(output_path) if output_path is not None else Path.home()
2035
+
2036
+ # Determine filename
2037
+ if filename is not None:
2038
+ chartname = output_directory / f"{filename}.svg"
2039
+ else:
2040
+ # Use default filename pattern
2041
+ chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2042
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Wheel Only.svg"
1828
2043
 
1829
2044
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1830
2045
  output_file.write(template)
1831
2046
 
1832
2047
  print(f"SVG Generated Correctly in: {chartname}")
1833
2048
 
1834
- def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
2049
+ def generate_aspect_grid_only_svg_string(self, minify: bool = False, remove_css_variables=False):
1835
2050
  """
1836
2051
  Render the aspect-grid-only chart SVG as a string.
1837
2052
 
@@ -1856,7 +2071,7 @@ class KerykeionChartSVG:
1856
2071
 
1857
2072
  template_dict = self._create_template_dictionary()
1858
2073
 
1859
- if self.chart_type in ["Transit", "Synastry", "Return"]:
2074
+ if self.chart_type in ["Transit", "Synastry", "DualReturnChart"]:
1860
2075
  aspects_grid = draw_transit_aspect_grid(
1861
2076
  self.chart_colors_settings["paper_0"],
1862
2077
  self.available_planets_setting,
@@ -1871,7 +2086,10 @@ class KerykeionChartSVG:
1871
2086
  y_start=250,
1872
2087
  )
1873
2088
 
1874
- template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
2089
+ # Use a compact, known-good viewBox that frames the grid
2090
+ viewbox_override = self._grid_only_viewbox()
2091
+
2092
+ template = Template(template).substitute({**template_dict.model_dump(), "makeAspectGrid": aspects_grid, "viewbox": viewbox_override})
1875
2093
 
1876
2094
  if remove_css_variables:
1877
2095
  template = inline_css_variables_in_svg(template)
@@ -1884,42 +2102,55 @@ class KerykeionChartSVG:
1884
2102
 
1885
2103
  return template
1886
2104
 
1887
- def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False):
2105
+ 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):
1888
2106
  """
1889
2107
  Generate and save aspect-grid-only chart SVG to disk.
1890
2108
 
1891
- Calls makeAspectGridOnlyTemplate and writes a file named
1892
- "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
2109
+ Calls generate_aspect_grid_only_svg_string and writes a file named
2110
+ "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the specified output directory.
1893
2111
 
1894
2112
  Args:
1895
- minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
1896
- remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
2113
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
2114
+ If None, defaults to the user's home directory.
2115
+ filename (str or None): Custom filename for the SVG file (without extension).
2116
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Aspect Grid Only".
2117
+ minify (bool): Pass-through to generate_aspect_grid_only_svg_string for compact output.
2118
+ remove_css_variables (bool): Pass-through to generate_aspect_grid_only_svg_string to embed CSS variables.
1897
2119
 
1898
2120
  Returns:
1899
2121
  None
1900
2122
  """
1901
2123
 
1902
- template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
1903
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Aspect Grid Only.svg"
2124
+ template = self.generate_aspect_grid_only_svg_string(minify, remove_css_variables)
2125
+
2126
+ # Convert output_path to Path object, default to home directory
2127
+ output_directory = Path(output_path) if output_path is not None else Path.home()
2128
+
2129
+ # Determine filename
2130
+ if filename is not None:
2131
+ chartname = output_directory / f"{filename}.svg"
2132
+ else:
2133
+ # Use default filename pattern
2134
+ chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2135
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Aspect Grid Only.svg"
1904
2136
 
1905
2137
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1906
2138
  output_file.write(template)
1907
2139
 
1908
2140
  print(f"SVG Generated Correctly in: {chartname}")
1909
2141
 
1910
-
1911
2142
  if __name__ == "__main__":
1912
2143
  from kerykeion.utilities import setup_logging
1913
2144
  from kerykeion.planetary_return_factory import PlanetaryReturnFactory
1914
2145
  from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
2146
+ from kerykeion.chart_data_factory import ChartDataFactory
2147
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
1915
2148
 
1916
- ACTIVE_PLANETS: list[AstrologicalPoint] = [
1917
- "Sun", "Moon", "Pars_Fortunae", "Mercury", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", "Chiron", "True_Node"
1918
- ]
1919
-
2149
+ ACTIVE_PLANETS: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS
2150
+ # ACTIVE_PLANETS: list[AstrologicalPoint] = ALL_ACTIVE_POINTS
1920
2151
  setup_logging(level="info")
1921
2152
 
1922
- subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
2153
+ subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB", active_points=ACTIVE_PLANETS)
1923
2154
 
1924
2155
  return_factory = PlanetaryReturnFactory(
1925
2156
  subject,
@@ -1932,101 +2163,118 @@ if __name__ == "__main__":
1932
2163
  )
1933
2164
 
1934
2165
  ###
1935
- ## Birth Chart
1936
- birth_chart = KerykeionChartSVG(
1937
- first_obj=subject,
2166
+ ## Birth Chart - NEW APPROACH with ChartDataFactory
2167
+ birth_chart_data = ChartDataFactory.create_natal_chart_data(
2168
+ subject,
2169
+ active_points=ACTIVE_PLANETS,
2170
+ )
2171
+ birth_chart = ChartDrawer(
2172
+ chart_data=birth_chart_data,
1938
2173
  chart_language="IT",
1939
2174
  theme="strawberry",
1940
- active_points=ACTIVE_PLANETS,
1941
2175
  )
1942
- birth_chart.makeSVG() # minify=True, remove_css_variables=True)
2176
+ birth_chart.save_svg() # minify=True, remove_css_variables=True)
1943
2177
 
1944
2178
  ###
1945
- ## Solar Return Chart
2179
+ ## Solar Return Chart - NEW APPROACH with ChartDataFactory
1946
2180
  solar_return = return_factory.next_return_from_iso_formatted_time(
1947
2181
  "2025-01-09T18:30:00+01:00", # UTC+1
1948
2182
  return_type="Solar",
1949
2183
  )
1950
- solar_return_chart = KerykeionChartSVG(
1951
- first_obj=subject, chart_type="Return",
1952
- second_obj=solar_return,
2184
+ solar_return_chart_data = ChartDataFactory.create_return_chart_data(
2185
+ subject,
2186
+ solar_return,
2187
+ active_points=ACTIVE_PLANETS,
2188
+ )
2189
+ solar_return_chart = ChartDrawer(
2190
+ chart_data=solar_return_chart_data,
1953
2191
  chart_language="IT",
1954
2192
  theme="classic",
1955
- active_points=ACTIVE_PLANETS,
1956
2193
  )
1957
2194
 
1958
- solar_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2195
+ solar_return_chart.save_svg() # minify=True, remove_css_variables=True)
1959
2196
 
1960
2197
  ###
1961
- ## Single wheel return
1962
- single_wheel_return_chart = KerykeionChartSVG(
1963
- first_obj=solar_return,
1964
- chart_type="SingleWheelReturn",
1965
- second_obj=solar_return,
2198
+ ## Single wheel return - NEW APPROACH with ChartDataFactory
2199
+ single_wheel_return_chart_data = ChartDataFactory.create_single_wheel_return_chart_data(
2200
+ solar_return,
2201
+ active_points=ACTIVE_PLANETS,
2202
+ )
2203
+ single_wheel_return_chart = ChartDrawer(
2204
+ chart_data=single_wheel_return_chart_data,
1966
2205
  chart_language="IT",
1967
2206
  theme="dark",
1968
- active_points=ACTIVE_PLANETS,
1969
2207
  )
1970
2208
 
1971
- single_wheel_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2209
+ single_wheel_return_chart.save_svg() # minify=True, remove_css_variables=True)
1972
2210
 
1973
2211
  ###
1974
- ## Lunar return
2212
+ ## Lunar return - NEW APPROACH with ChartDataFactory
1975
2213
  lunar_return = return_factory.next_return_from_iso_formatted_time(
1976
2214
  "2025-01-09T18:30:00+01:00", # UTC+1
1977
2215
  return_type="Lunar",
1978
2216
  )
1979
- lunar_return_chart = KerykeionChartSVG(
1980
- first_obj=subject,
1981
- chart_type="Return",
1982
- second_obj=lunar_return,
2217
+ lunar_return_chart_data = ChartDataFactory.create_return_chart_data(
2218
+ subject,
2219
+ lunar_return,
2220
+ active_points=ACTIVE_PLANETS,
2221
+ )
2222
+ lunar_return_chart = ChartDrawer(
2223
+ chart_data=lunar_return_chart_data,
1983
2224
  chart_language="IT",
1984
2225
  theme="dark",
1985
- active_points=ACTIVE_PLANETS,
1986
2226
  )
1987
- lunar_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2227
+ lunar_return_chart.save_svg() # minify=True, remove_css_variables=True)
1988
2228
 
1989
2229
  ###
1990
- ## Transit Chart
2230
+ ## Transit Chart - NEW APPROACH with ChartDataFactory
1991
2231
  transit = AstrologicalSubjectFactory.from_iso_utc_time(
1992
2232
  "Transit",
1993
2233
  "2021-10-04T18:30:00+01:00",
1994
2234
  )
1995
- transit_chart = KerykeionChartSVG(
1996
- first_obj=subject,
1997
- chart_type="Transit",
1998
- second_obj=transit,
2235
+ transit_chart_data = ChartDataFactory.create_transit_chart_data(
2236
+ subject,
2237
+ transit,
2238
+ active_points=ACTIVE_PLANETS,
2239
+ )
2240
+ transit_chart = ChartDrawer(
2241
+ chart_data=transit_chart_data,
1999
2242
  chart_language="IT",
2000
2243
  theme="dark",
2001
- active_points=ACTIVE_PLANETS
2002
2244
  )
2003
- transit_chart.makeSVG() # minify=True, remove_css_variables=True)
2245
+ transit_chart.save_svg() # minify=True, remove_css_variables=True)
2004
2246
 
2005
2247
  ###
2006
- ## Synastry Chart
2248
+ ## Synastry Chart - NEW APPROACH with ChartDataFactory
2007
2249
  second_subject = AstrologicalSubjectFactory.from_birth_data("Yoko Ono", 1933, 2, 18, 18, 30, "Tokyo", "JP")
2008
- synastry_chart = KerykeionChartSVG(
2009
- first_obj=subject,
2010
- chart_type="Synastry",
2011
- second_obj=second_subject,
2250
+ synastry_chart_data = ChartDataFactory.create_synastry_chart_data(
2251
+ subject,
2252
+ second_subject,
2253
+ active_points=ACTIVE_PLANETS,
2254
+ )
2255
+ synastry_chart = ChartDrawer(
2256
+ chart_data=synastry_chart_data,
2012
2257
  chart_language="IT",
2013
2258
  theme="dark",
2014
- active_points=ACTIVE_PLANETS
2015
2259
  )
2016
- synastry_chart.makeSVG() # minify=True, remove_css_variables=True)
2260
+ synastry_chart.save_svg() # minify=True, remove_css_variables=True)
2017
2261
 
2018
2262
  ##
2019
- # Transit Chart with Grid
2263
+ # Transit Chart with Grid - NEW APPROACH with ChartDataFactory
2020
2264
  subject.name = "Grid"
2021
- transit_chart_with_grid = KerykeionChartSVG(
2022
- first_obj=subject,
2023
- chart_type="Transit",
2024
- second_obj=transit,
2265
+ transit_chart_with_grid_data = ChartDataFactory.create_transit_chart_data(
2266
+ subject,
2267
+ transit,
2268
+ active_points=ACTIVE_PLANETS,
2269
+ )
2270
+ transit_chart_with_grid = ChartDrawer(
2271
+ chart_data=transit_chart_with_grid_data,
2025
2272
  chart_language="IT",
2026
2273
  theme="dark",
2027
- active_points=ACTIVE_PLANETS,
2028
2274
  double_chart_aspect_grid_type="table"
2029
2275
  )
2030
- transit_chart_with_grid.makeSVG() # minify=True, remove_css_variables=True)
2031
- transit_chart_with_grid.makeAspectGridOnlySVG()
2032
- transit_chart_with_grid.makeWheelOnlySVG()
2276
+ transit_chart_with_grid.save_svg() # minify=True, remove_css_variables=True)
2277
+ transit_chart_with_grid.save_aspect_grid_only_svg_file()
2278
+ transit_chart_with_grid.save_wheel_only_svg_file()
2279
+
2280
+ print("✅ All chart examples completed using ChartDataFactory + ChartDrawer architecture!")