kerykeion 5.0.0a12__py3-none-any.whl → 5.0.0b1__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} +612 -438
  8. kerykeion/charts/charts_utils.py +135 -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 +104 -28
  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 +340 -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 +130 -40
  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.0b1.dist-info}/METADATA +507 -120
  45. kerykeion-5.0.0b1.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.0b1.dist-info}/WHEEL +0 -0
  51. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b1.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,60 @@ 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
160
163
 
161
164
  _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}"
162
165
  _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0"
@@ -167,9 +170,6 @@ class KerykeionChartSVG:
167
170
  first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
168
171
  second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None]
169
172
  chart_type: ChartType
170
- new_output_directory: Union[Path, None]
171
- new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
172
- output_directory: Path
173
173
  new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
174
174
  theme: Union[KerykeionChartTheme, None]
175
175
  double_chart_aspect_grid_type: Literal["list", "table"]
@@ -177,6 +177,8 @@ class KerykeionChartSVG:
177
177
  active_points: List[AstrologicalPoint]
178
178
  active_aspects: List[ActiveAspect]
179
179
  transparent_background: bool
180
+ external_view: bool
181
+ custom_title: Union[str, None]
180
182
 
181
183
  # Internal properties
182
184
  fire: float
@@ -189,8 +191,8 @@ class KerykeionChartSVG:
189
191
  width: Union[float, int]
190
192
  language_settings: dict
191
193
  chart_colors_settings: dict
192
- planets_settings: dict
193
- aspects_settings: dict
194
+ planets_settings: list[dict[Any, Any]]
195
+ aspects_settings: list[dict[Any, Any]]
194
196
  available_planets_setting: List[KerykeionSettingsCelestialPointModel]
195
197
  height: float
196
198
  location: str
@@ -200,34 +202,28 @@ class KerykeionChartSVG:
200
202
 
201
203
  def __init__(
202
204
  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,
205
+ chart_data: "ChartDataModel",
206
+ *,
207
207
  new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
208
208
  theme: Union[KerykeionChartTheme, None] = "classic",
209
209
  double_chart_aspect_grid_type: Literal["list", "table"] = "list",
210
210
  chart_language: KerykeionChartLanguage = "EN",
211
- active_points: Optional[list[AstrologicalPoint]] = None,
212
- active_aspects: list[ActiveAspect]= DEFAULT_ACTIVE_ASPECTS,
213
- *,
211
+ external_view: bool = False,
214
212
  transparent_background: bool = False,
215
213
  colors_settings: dict = DEFAULT_CHART_COLORS,
216
214
  celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
217
215
  aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
216
+ custom_title: Union[str, None] = None,
217
+ auto_size: bool = True,
218
+ padding: int = 20,
218
219
  ):
219
220
  """
220
- Initialize the chart generator with subject data and configuration options.
221
+ Initialize the chart visualizer with pre-computed chart data.
221
222
 
222
223
  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.
224
+ chart_data (ChartDataModel):
225
+ Pre-computed chart data from ChartDataFactory containing all subjects,
226
+ aspects, element/quality distributions, and other analytical data.
231
227
  new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
232
228
  Custom settings source for chart colors, fonts, and aspects.
233
229
  theme (KerykeionChartTheme or None, optional):
@@ -236,99 +232,82 @@ class KerykeionChartSVG:
236
232
  Layout style for double-chart aspect grids ('list' or 'table').
237
233
  chart_language (KerykeionChartLanguage, optional):
238
234
  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.
235
+ external_view (bool, optional):
236
+ Whether to use external visualization (planets on outer ring) for single-subject charts. Defaults to False.
243
237
  transparent_background (bool, optional):
244
238
  Whether to use a transparent background instead of the theme color. Defaults to False.
239
+ custom_title (str or None, optional):
240
+ Custom title for the chart. If None, the default title will be used based on chart type. Defaults to None.
245
241
  """
246
242
  # --------------------
247
243
  # COMMON INITIALIZATION
248
244
  # --------------------
249
- home_directory = Path.home()
250
245
  self.new_settings_file = new_settings_file
251
246
  self.chart_language = chart_language
252
- self.active_aspects = active_aspects
253
- self.chart_type = chart_type
254
247
  self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
255
248
  self.transparent_background = transparent_background
249
+ self.external_view = external_view
256
250
  self.chart_colors_settings = colors_settings
257
251
  self.planets_settings = celestial_points_settings
258
252
  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
253
+ self.custom_title = custom_title
254
+ self.auto_size = auto_size
255
+ self._padding = padding
256
+
257
+ # Extract data from ChartDataModel
258
+ self.chart_data = chart_data
259
+ self.chart_type = chart_data.chart_type
260
+ self.active_points = chart_data.active_points
261
+ self.active_aspects = chart_data.active_aspects
262
+
263
+ # Extract subjects based on chart type
264
+ if chart_data.chart_type in ["Natal", "Composite", "SingleReturnChart"]:
265
+ # SingleChartDataModel
266
+ self.first_obj = getattr(chart_data, 'subject')
267
+ self.second_obj = None
268
+ else: # DualChartDataModel for Transit, Synastry, DualReturnChart
269
+ self.first_obj = getattr(chart_data, 'first_subject')
270
+ self.second_obj = getattr(chart_data, 'second_subject')
279
271
 
280
272
  # Load settings
281
273
  self.parse_json_settings(new_settings_file)
282
274
 
283
- # Primary subject
284
- self.first_obj = first_obj
285
-
286
275
  # Default radius for all charts
287
276
  self.main_radius = 240
288
277
 
289
- # Configure available planets
278
+ # Configure available planets from chart data
290
279
  self.available_planets_setting = []
291
280
  for body in self.planets_settings:
292
281
  if body["name"] in self.active_points:
293
282
  body["is_active"] = True
294
- self.available_planets_setting.append(body)
283
+ self.available_planets_setting.append(body) # type: ignore[arg-type]
295
284
 
296
285
  # Set available celestial points
297
286
  available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
298
287
  self.available_kerykeion_celestial_points = []
299
288
  for body in available_celestial_points_names:
300
- self.available_kerykeion_celestial_points.append(self.first_obj.get(body))
289
+ if hasattr(self.first_obj, body):
290
+ self.available_kerykeion_celestial_points.append(self.first_obj.get(body)) # type: ignore[arg-type]
301
291
 
302
292
  # ------------------------
303
- # CHART TYPE SPECIFIC SETUP
293
+ # CHART TYPE SPECIFIC SETUP FROM CHART DATA
304
294
  # ------------------------
305
295
 
306
- if self.chart_type in ["Natal", "ExternalNatal"]:
307
- # --- NATAL / EXTERNAL NATAL CHART SETUP ---
296
+ if self.chart_type == "Natal":
297
+ # --- NATAL CHART SETUP ---
308
298
 
309
- # Validate Subject
310
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
311
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
312
-
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
299
+ # Extract aspects from pre-computed chart data
300
+ self.aspects_list = chart_data.aspects.relevant_aspects
320
301
 
321
302
  # Screen size
322
303
  self.height = self._DEFAULT_HEIGHT
323
304
  self.width = self._DEFAULT_NATAL_WIDTH
324
305
 
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
306
+ # Get location and coordinates
307
+ self.location, self.geolat, self.geolon = self._get_location_info()
329
308
 
330
- # Circle radii
331
- if self.chart_type == "ExternalNatal":
309
+ # Circle radii - depends on external_view
310
+ if self.external_view:
332
311
  self.first_circle_radius = 56
333
312
  self.second_circle_radius = 92
334
313
  self.third_circle_radius = 112
@@ -340,21 +319,15 @@ class KerykeionChartSVG:
340
319
  elif self.chart_type == "Composite":
341
320
  # --- COMPOSITE CHART SETUP ---
342
321
 
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
322
+ # Extract aspects from pre-computed chart data
323
+ self.aspects_list = chart_data.aspects.relevant_aspects
349
324
 
350
325
  # Screen size
351
326
  self.height = self._DEFAULT_HEIGHT
352
327
  self.width = self._DEFAULT_NATAL_WIDTH
353
328
 
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
329
+ # Get location and coordinates
330
+ self.location, self.geolat, self.geolon = self._get_location_info()
358
331
 
359
332
  # Circle radii
360
333
  self.first_circle_radius = 0
@@ -364,25 +337,8 @@ class KerykeionChartSVG:
364
337
  elif self.chart_type == "Transit":
365
338
  # --- TRANSIT CHART SETUP ---
366
339
 
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
340
+ # Extract aspects from pre-computed chart data
341
+ self.aspects_list = chart_data.aspects.relevant_aspects
386
342
 
387
343
  # Secondary subject available points
388
344
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -394,11 +350,8 @@ class KerykeionChartSVG:
394
350
  else:
395
351
  self.width = self._DEFAULT_FULL_WIDTH
396
352
 
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"]
353
+ # Get location and coordinates
354
+ self.location, self.geolat, self.geolon = self._get_location_info()
402
355
 
403
356
  # Circle radii
404
357
  self.first_circle_radius = 0
@@ -408,25 +361,8 @@ class KerykeionChartSVG:
408
361
  elif self.chart_type == "Synastry":
409
362
  # --- SYNASTRY CHART SETUP ---
410
363
 
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
364
+ # Extract aspects from pre-computed chart data
365
+ self.aspects_list = chart_data.aspects.relevant_aspects
430
366
 
431
367
  # Secondary subject available points
432
368
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -435,38 +371,19 @@ class KerykeionChartSVG:
435
371
  self.height = self._DEFAULT_HEIGHT
436
372
  self.width = self._DEFAULT_FULL_WIDTH
437
373
 
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
374
+ # Get location and coordinates
375
+ self.location, self.geolat, self.geolon = self._get_location_info()
442
376
 
443
377
  # Circle radii
444
378
  self.first_circle_radius = 0
445
379
  self.second_circle_radius = 36
446
380
  self.third_circle_radius = 120
447
381
 
448
- elif self.chart_type == "Return":
382
+ elif self.chart_type == "DualReturnChart":
449
383
  # --- RETURN CHART SETUP ---
450
384
 
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
385
+ # Extract aspects from pre-computed chart data
386
+ self.aspects_list = chart_data.aspects.relevant_aspects
470
387
 
471
388
  # Secondary subject available points
472
389
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -475,93 +392,45 @@ class KerykeionChartSVG:
475
392
  self.height = self._DEFAULT_HEIGHT
476
393
  self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
477
394
 
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
395
+ # Get location and coordinates
396
+ self.location, self.geolat, self.geolon = self._get_location_info()
482
397
 
483
398
  # Circle radii
484
399
  self.first_circle_radius = 0
485
400
  self.second_circle_radius = 36
486
401
  self.third_circle_radius = 120
487
402
 
488
- elif self.chart_type == "SingleWheelReturn":
489
- # --- NATAL / EXTERNAL NATAL CHART SETUP ---
490
-
491
- # Validate Subject
492
- if not isinstance(self.first_obj, PlanetReturnModel):
493
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
403
+ elif self.chart_type == "SingleReturnChart":
404
+ # --- SINGLE WHEEL RETURN CHART SETUP ---
494
405
 
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
406
+ # Extract aspects from pre-computed chart data
407
+ self.aspects_list = chart_data.aspects.relevant_aspects
502
408
 
503
409
  # Screen size
504
410
  self.height = self._DEFAULT_HEIGHT
505
411
  self.width = self._DEFAULT_NATAL_WIDTH
506
412
 
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
413
+ # Get location and coordinates
414
+ self.location, self.geolat, self.geolon = self._get_location_info()
511
415
 
512
416
  # 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
417
+ self.first_circle_radius = 0
418
+ self.second_circle_radius = 36
419
+ self.third_circle_radius = 120
521
420
 
522
421
  # --------------------
523
- # FINAL COMMON SETUP
422
+ # FINAL COMMON SETUP FROM CHART DATA
524
423
  # --------------------
525
424
 
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
- )
541
-
542
- self.fire = element_totals["fire"]
543
- self.earth = element_totals["earth"]
544
- self.air = element_totals["air"]
545
- self.water = element_totals["water"]
425
+ # Extract pre-computed element and quality distributions
426
+ self.fire = chart_data.element_distribution.fire
427
+ self.earth = chart_data.element_distribution.earth
428
+ self.air = chart_data.element_distribution.air
429
+ self.water = chart_data.element_distribution.water
546
430
 
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"]
431
+ self.cardinal = chart_data.quality_distribution.cardinal
432
+ self.fixed = chart_data.quality_distribution.fixed
433
+ self.mutable = chart_data.quality_distribution.mutable
565
434
 
566
435
  # Set up theme
567
436
  if theme not in get_args(KerykeionChartTheme) and theme is not None:
@@ -569,6 +438,176 @@ class KerykeionChartSVG:
569
438
 
570
439
  self.set_up_theme(theme)
571
440
 
441
+ # Optionally expand width dynamically to fit content
442
+ if self.auto_size:
443
+ try:
444
+ required_width = self._estimate_required_width_full()
445
+ if required_width > self.width:
446
+ self.width = required_width
447
+ except Exception as e:
448
+ # Keep default on any unexpected issue; do not break rendering
449
+ logging.debug(f"Auto-size width calculation failed: {e}")
450
+
451
+ def _count_active_planets(self) -> int:
452
+ """Return number of active celestial points in the current chart."""
453
+ return len([p for p in self.available_planets_setting if p.get("is_active")])
454
+
455
+ def _dynamic_viewbox(self) -> str:
456
+ """Return the viewBox string based on current width/height."""
457
+ return f"0 0 {int(self.width)} {int(self.height)}"
458
+
459
+ def _wheel_only_viewbox(self, margin: int = 20) -> str:
460
+ """Return a tight viewBox for the wheel-only template.
461
+
462
+ The wheel is drawn inside a group translated by (100, 50) and has
463
+ diameter 2 * main_radius. We add a small margin around it.
464
+ """
465
+ left = 100 - margin
466
+ top = 50 - margin
467
+ width = (2 * self.main_radius) + (2 * margin)
468
+ height = (2 * self.main_radius) + (2 * margin)
469
+ return f"{left} {top} {width} {height}"
470
+
471
+ def _grid_only_viewbox(self, margin: int = 10) -> str:
472
+ """Compute a tight viewBox for the Aspect Grid Only SVG.
473
+
474
+ The grid is rendered using fixed origins and box size:
475
+ - For Transit/Synastry/DualReturn charts, `draw_transit_aspect_grid`
476
+ uses `x_indent=50`, `y_indent=250`, `box_size=14` and draws:
477
+ • a header row to the right of `x_indent`
478
+ • a left header column at `x_indent - box_size`
479
+ • an N×N grid of cells above `y_indent`
480
+
481
+ - For Natal/Composite/SingleReturn charts, `draw_aspect_grid` uses
482
+ `x_start=50`, `y_start=250`, `box_size=14` and draws a triangular grid
483
+ that extends to the right (x) and upwards (y).
484
+
485
+ This function mirrors that geometry to return a snug viewBox around the
486
+ content, with a small configurable `margin`.
487
+
488
+ Args:
489
+ margin: Extra pixels to add on each side of the computed bounds.
490
+
491
+ Returns:
492
+ A string "minX minY width height" suitable for the SVG `viewBox`.
493
+ """
494
+ # Must match defaults used in the renderers
495
+ x0 = 50
496
+ y0 = 250
497
+ box = 14
498
+
499
+ n = max(len([p for p in self.available_planets_setting if p.get("is_active")]), 1)
500
+
501
+ if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
502
+ # Full N×N grid
503
+ left = (x0 - box) - margin
504
+ top = (y0 - box * n) - margin
505
+ right = (x0 + box * n) + margin
506
+ bottom = (y0 + box) + margin
507
+ else:
508
+ # Triangular grid (no extra left column)
509
+ left = x0 - margin
510
+ top = (y0 - box * n) - margin
511
+ right = (x0 + box * n) + margin
512
+ bottom = (y0 + box) + margin
513
+
514
+ width = max(1, int(right - left))
515
+ height = max(1, int(bottom - top))
516
+
517
+ return f"{int(left)} {int(top)} {width} {height}"
518
+
519
+ def _estimate_required_width_full(self) -> int:
520
+ """Estimate minimal width to contain all rendered groups for the full chart.
521
+
522
+ The calculation is heuristic and mirrors the default x positions used in
523
+ the SVG templates and drawing utilities. We keep a conservative padding.
524
+ """
525
+ # Wheel footprint (translate(100,50) + diameter of 2*radius)
526
+ wheel_right = 100 + (2 * self.main_radius)
527
+ extents = [wheel_right]
528
+
529
+ n_active = max(self._count_active_planets(), 1)
530
+
531
+ # Common grids present on many chart types
532
+ main_planet_grid_right = 645 + 80
533
+ main_houses_grid_right = 750 + 120
534
+ extents.extend([main_planet_grid_right, main_houses_grid_right])
535
+
536
+ if self.chart_type in ("Natal", "Composite", "SingleReturnChart"):
537
+ # Triangular aspect grid at x_start=510, width ~ 14 * n_active
538
+ aspect_grid_right = 510 + 14 * n_active
539
+ extents.append(aspect_grid_right)
540
+
541
+ if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
542
+ # Double-chart aspects placement
543
+ if self.double_chart_aspect_grid_type == "list":
544
+ # Columnar list placed at translate(565,273), ~100-110px per column, 14 aspects per column
545
+ aspects_per_column = 14
546
+ total_aspects = len(self.aspects_list) if hasattr(self, "aspects_list") else 0
547
+ columns = max((total_aspects + aspects_per_column - 1) // aspects_per_column, 1)
548
+ # Respect the max columns cap used in rendering: DualReturn=7, others=6
549
+ max_cols_cap = 7
550
+ columns = min(columns, max_cols_cap)
551
+ aspect_list_right = 565 + (columns * 110)
552
+ extents.append(aspect_list_right)
553
+ else:
554
+ # Grid table placed with x_indent ~550, width ~ 14px per cell across n_active+1
555
+ aspect_grid_table_right = 550 + (14 * (n_active + 1))
556
+ extents.append(aspect_grid_table_right)
557
+
558
+ # Secondary grids
559
+ secondary_planet_grid_right = 910 + 80
560
+ extents.append(secondary_planet_grid_right)
561
+
562
+ if self.chart_type == "Synastry":
563
+ # Secondary houses grid default x ~ 1015
564
+ secondary_houses_grid_right = 1015 + 120
565
+ extents.append(secondary_houses_grid_right)
566
+
567
+ if self.chart_type == "Transit":
568
+ # House comparison grid at x ~ 1030
569
+ house_comparison_grid_right = 1030 + 180
570
+ extents.append(house_comparison_grid_right)
571
+
572
+ if self.chart_type == "DualReturnChart":
573
+ # House comparison grid at x ~ 1030
574
+ house_comparison_grid_right = 1030 + 320
575
+ extents.append(house_comparison_grid_right)
576
+
577
+ # Conservative safety padding
578
+ return int(max(extents) + self._padding)
579
+
580
+ def _get_location_info(self) -> tuple[str, float, float]:
581
+ """
582
+ Determine location information based on chart type and subjects.
583
+
584
+ Returns:
585
+ tuple: (location_name, latitude, longitude)
586
+ """
587
+ if self.chart_type == "Composite":
588
+ # For composite charts, use average location of the two composite subjects
589
+ if isinstance(self.first_obj, CompositeSubjectModel):
590
+ location_name = ""
591
+ latitude = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
592
+ longitude = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
593
+ else:
594
+ # Fallback to first subject location
595
+ location_name = self.first_obj.city or "Unknown"
596
+ latitude = self.first_obj.lat or 0.0
597
+ longitude = self.first_obj.lng or 0.0
598
+ elif self.chart_type in ["Transit", "DualReturnChart"] and self.second_obj:
599
+ # Use location from the second subject (transit/return)
600
+ location_name = self.second_obj.city or "Unknown"
601
+ latitude = self.second_obj.lat or 0.0
602
+ longitude = self.second_obj.lng or 0.0
603
+ else:
604
+ # Use location from the first subject
605
+ location_name = self.first_obj.city or "Unknown"
606
+ latitude = self.first_obj.lat or 0.0
607
+ longitude = self.first_obj.lng or 0.0
608
+
609
+ return location_name, latitude, longitude
610
+
572
611
  def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
573
612
  """
574
613
  Load and apply a CSS theme for the chart visualization.
@@ -585,16 +624,6 @@ class KerykeionChartSVG:
585
624
  with open(theme_dir / f"{theme}.css", "r") as f:
586
625
  self.color_style_tag = f.read()
587
626
 
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
627
  def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
599
628
  """
600
629
  Load and parse chart configuration settings.
@@ -682,7 +711,89 @@ class KerykeionChartSVG:
682
711
  )
683
712
  return out
684
713
 
685
- def _create_template_dictionary(self) -> ChartTemplateDictionary:
714
+ def _truncate_name(self, name: str, max_length: int = 50) -> str:
715
+ """
716
+ Truncate a name if it's too long, preserving readability.
717
+
718
+ Args:
719
+ name (str): The name to truncate
720
+ max_length (int): Maximum allowed length
721
+
722
+ Returns:
723
+ str: Truncated name with ellipsis if needed
724
+ """
725
+ if len(name) <= max_length:
726
+ return name
727
+ return name[:max_length-1] + "…"
728
+
729
+ def _get_chart_title(self) -> str:
730
+ """
731
+ Generate the chart title based on chart type and custom title settings.
732
+
733
+ If a custom title is provided, it will be used. Otherwise, generates the
734
+ appropriate default title based on the chart type and subjects.
735
+
736
+ Returns:
737
+ str: The chart title to display (max ~40 characters).
738
+ """
739
+ # If custom title is provided, use it
740
+ if self.custom_title is not None:
741
+ return self.custom_title
742
+
743
+ # Generate default title based on chart type
744
+ if self.chart_type == "Natal":
745
+ natal_label = self.language_settings.get("birth_chart", "Natal")
746
+ truncated_name = self._truncate_name(self.first_obj.name)
747
+ return f'{truncated_name} - {natal_label}'
748
+
749
+ elif self.chart_type == "Composite":
750
+ composite_label = self.language_settings.get("composite_chart", "Composite")
751
+ and_word = self.language_settings.get("and_word", "&")
752
+ name1 = self._truncate_name(self.first_obj.first_subject.name) # type: ignore
753
+ name2 = self._truncate_name(self.first_obj.second_subject.name) # type: ignore
754
+ return f"{composite_label}: {name1} {and_word} {name2}"
755
+
756
+ elif self.chart_type == "Transit":
757
+ transit_label = self.language_settings.get("transits", "Transits")
758
+ from datetime import datetime
759
+ date_obj = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime) # type: ignore
760
+ date_str = date_obj.strftime("%d/%m/%y")
761
+ truncated_name = self._truncate_name(self.first_obj.name)
762
+ return f"{truncated_name} - {transit_label} {date_str}"
763
+
764
+ elif self.chart_type == "Synastry":
765
+ synastry_label = self.language_settings.get("synastry_chart", "Synastry")
766
+ and_word = self.language_settings.get("and_word", "&")
767
+ name1 = self._truncate_name(self.first_obj.name)
768
+ name2 = self._truncate_name(self.second_obj.name) # type: ignore
769
+ return f"{synastry_label}: {name1} {and_word} {name2}"
770
+
771
+ elif self.chart_type == "DualReturnChart":
772
+ from datetime import datetime
773
+ year = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime).year # type: ignore
774
+ truncated_name = self._truncate_name(self.first_obj.name)
775
+ if self.second_obj is not None and isinstance(self.second_obj, PlanetReturnModel) and self.second_obj.return_type == "Solar":
776
+ solar_label = self.language_settings.get("solar_return", "Solar")
777
+ return f"{truncated_name} - {solar_label} {year}"
778
+ else:
779
+ lunar_label = self.language_settings.get("lunar_return", "Lunar")
780
+ return f"{truncated_name} - {lunar_label} {year}"
781
+
782
+ elif self.chart_type == "SingleReturnChart":
783
+ from datetime import datetime
784
+ year = datetime.fromisoformat(self.first_obj.iso_formatted_local_datetime).year # type: ignore
785
+ truncated_name = self._truncate_name(self.first_obj.name)
786
+ if isinstance(self.first_obj, PlanetReturnModel) and self.first_obj.return_type == "Solar":
787
+ solar_label = self.language_settings.get("solar_return", "Solar")
788
+ return f"{truncated_name} - {solar_label} {year}"
789
+ else:
790
+ lunar_label = self.language_settings.get("lunar_return", "Lunar")
791
+ return f"{truncated_name} - {lunar_label} {year}"
792
+
793
+ # Fallback for unknown chart types
794
+ return self._truncate_name(self.first_obj.name)
795
+
796
+ def _create_template_dictionary(self) -> ChartTemplateModel:
686
797
  """
687
798
  Assemble chart data and rendering instructions into a template dictionary.
688
799
 
@@ -690,7 +801,7 @@ class KerykeionChartSVG:
690
801
  chart type and subjects.
691
802
 
692
803
  Returns:
693
- ChartTemplateDictionary: Populated structure of template variables.
804
+ ChartTemplateModel: Populated structure of template variables.
694
805
  """
695
806
  # Initialize template dictionary
696
807
  template_dict: dict = {}
@@ -706,7 +817,6 @@ class KerykeionChartSVG:
706
817
 
707
818
  # Set paper colors
708
819
  template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
709
- template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"]
710
820
 
711
821
  # Set background color based on transparent_background setting
712
822
  if self.transparent_background:
@@ -714,7 +824,12 @@ class KerykeionChartSVG:
714
824
  else:
715
825
  template_dict["background_color"] = self.chart_colors_settings["paper_1"]
716
826
 
717
- # Set planet colors
827
+ # Set planet colors - initialize all possible colors first with defaults
828
+ default_color = "#000000" # Default black color for unused planets
829
+ for i in range(42): # Support all 42 celestial points (0-41)
830
+ template_dict[f"planets_color_{i}"] = default_color
831
+
832
+ # Override with actual colors from settings
718
833
  for planet in self.planets_settings:
719
834
  planet_id = planet["id"]
720
835
  template_dict[f"planets_color_{planet_id}"] = planet["color"]
@@ -732,10 +847,12 @@ class KerykeionChartSVG:
732
847
 
733
848
  # Calculate element percentages
734
849
  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))
850
+ element_values = {"fire": self.fire, "earth": self.earth, "air": self.air, "water": self.water}
851
+ element_percentages = distribute_percentages_to_100(element_values) if total_elements > 0 else {"fire": 0, "earth": 0, "air": 0, "water": 0}
852
+ fire_percentage = element_percentages["fire"]
853
+ earth_percentage = element_percentages["earth"]
854
+ air_percentage = element_percentages["air"]
855
+ water_percentage = element_percentages["water"]
739
856
 
740
857
  # Element Percentages
741
858
  template_dict["elements_string"] = f"{self.language_settings.get('elements', 'Elements')}:"
@@ -747,9 +864,11 @@ class KerykeionChartSVG:
747
864
 
748
865
  # Qualities Percentages
749
866
  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))
867
+ quality_values = {"cardinal": self.cardinal, "fixed": self.fixed, "mutable": self.mutable}
868
+ quality_percentages = distribute_percentages_to_100(quality_values) if total_qualities > 0 else {"cardinal": 0, "fixed": 0, "mutable": 0}
869
+ cardinal_percentage = quality_percentages["cardinal"]
870
+ fixed_percentage = quality_percentages["fixed"]
871
+ mutable_percentage = quality_percentages["mutable"]
753
872
 
754
873
  template_dict["qualities_string"] = f"{self.language_settings.get('qualities', 'Qualities')}:"
755
874
  template_dict["cardinal_string"] = f"{self.language_settings.get('cardinal', 'Cardinal')} {cardinal_percentage}%"
@@ -759,13 +878,16 @@ class KerykeionChartSVG:
759
878
  # Get houses list for main subject
760
879
  first_subject_houses_list = get_houses_list(self.first_obj)
761
880
 
881
+ # Chart title
882
+ template_dict["stringTitle"] = self._get_chart_title()
883
+
762
884
  # ------------------------------- #
763
885
  # CHART TYPE SPECIFIC SETTINGS #
764
886
  # ------------------------------- #
765
887
 
766
- if self.chart_type in ["Natal", "ExternalNatal"]:
767
- # Set viewbox
768
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
888
+ if self.chart_type == "Natal":
889
+ # Set viewbox dynamically
890
+ template_dict["viewbox"] = self._dynamic_viewbox()
769
891
 
770
892
  # Rings and circles
771
893
  template_dict["transitRing"] = ""
@@ -810,9 +932,6 @@ class KerykeionChartSVG:
810
932
  )
811
933
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
812
934
 
813
- # Chart title
814
- template_dict["stringTitle"] = f'{self.first_obj.name} - {self.language_settings.get("birth_chart", "Birth Chart")}'
815
-
816
935
  # Top left section
817
936
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
818
937
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -822,7 +941,8 @@ class KerykeionChartSVG:
822
941
  template_dict["top_left_2"] = f"{self.language_settings['latitude']}: {latitude_string}"
823
942
  template_dict["top_left_3"] = f"{self.language_settings['longitude']}: {longitude_string}"
824
943
  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
944
+ localized_weekday = self.language_settings.get('weekdays', {}).get(self.first_obj.day_of_week, self.first_obj.day_of_week) # type: ignore
945
+ template_dict["top_left_5"] = f"{self.language_settings.get('day_of_week', 'Day of Week')}: {localized_weekday}" # type: ignore
826
946
 
827
947
  # Bottom left section
828
948
  if self.first_obj.zodiac_type == "Tropic":
@@ -870,6 +990,7 @@ class KerykeionChartSVG:
870
990
  c1=self.first_circle_radius,
871
991
  c3=self.third_circle_radius,
872
992
  chart_type=self.chart_type,
993
+ external_view=self.external_view,
873
994
  )
874
995
 
875
996
  template_dict["makePlanets"] = draw_planets(
@@ -880,6 +1001,7 @@ class KerykeionChartSVG:
880
1001
  third_circle_radius=self.third_circle_radius,
881
1002
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
882
1003
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1004
+ external_view=self.external_view,
883
1005
  )
884
1006
 
885
1007
  template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
@@ -894,8 +1016,8 @@ class KerykeionChartSVG:
894
1016
  template_dict["makeHouseComparisonGrid"] = ""
895
1017
 
896
1018
  elif self.chart_type == "Composite":
897
- # Set viewbox
898
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1019
+ # Set viewbox dynamically
1020
+ template_dict["viewbox"] = self._dynamic_viewbox()
899
1021
 
900
1022
  # Rings and circles
901
1023
  template_dict["transitRing"] = ""
@@ -940,9 +1062,6 @@ class KerykeionChartSVG:
940
1062
  )
941
1063
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
942
1064
 
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
1065
  # Top left section
947
1066
  # First subject
948
1067
  latitude = convert_latitude_coordinate_to_string(
@@ -1014,6 +1133,7 @@ class KerykeionChartSVG:
1014
1133
  c1=self.first_circle_radius,
1015
1134
  c3=self.third_circle_radius,
1016
1135
  chart_type=self.chart_type,
1136
+ external_view=self.external_view,
1017
1137
  )
1018
1138
 
1019
1139
  template_dict["makePlanets"] = draw_planets(
@@ -1024,6 +1144,7 @@ class KerykeionChartSVG:
1024
1144
  third_circle_radius=self.third_circle_radius,
1025
1145
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1026
1146
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1147
+ external_view=self.external_view,
1027
1148
  )
1028
1149
 
1029
1150
  subject_name = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
@@ -1054,11 +1175,8 @@ class KerykeionChartSVG:
1054
1175
  template_dict["fixed_string"] = ""
1055
1176
  template_dict["mutable_string"] = ""
1056
1177
 
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
1178
+ # Set viewbox dynamically
1179
+ template_dict["viewbox"] = self._dynamic_viewbox()
1062
1180
 
1063
1181
  # Get houses list for secondary subject
1064
1182
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1098,7 +1216,7 @@ class KerykeionChartSVG:
1098
1216
  if self.double_chart_aspect_grid_type == "list":
1099
1217
  title = f'{self.first_obj.name} - {self.language_settings.get("transit_aspects", "Transit Aspects")}'
1100
1218
  template_dict["makeAspectGrid"] = ""
1101
- template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings)
1219
+ 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
1220
  else:
1103
1221
  template_dict["makeAspectGrid"] = ""
1104
1222
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
@@ -1111,9 +1229,6 @@ class KerykeionChartSVG:
1111
1229
 
1112
1230
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1113
1231
 
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
1232
  # Top left section
1118
1233
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1119
1234
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -1176,6 +1291,7 @@ class KerykeionChartSVG:
1176
1291
  c1=self.first_circle_radius,
1177
1292
  c3=self.third_circle_radius,
1178
1293
  chart_type=self.chart_type,
1294
+ external_view=self.external_view,
1179
1295
  second_subject_houses_list=second_subject_houses_list,
1180
1296
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1181
1297
  )
@@ -1189,6 +1305,7 @@ class KerykeionChartSVG:
1189
1305
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1190
1306
  chart_type=self.chart_type,
1191
1307
  third_circle_radius=self.third_circle_radius,
1308
+ external_view=self.external_view,
1192
1309
  )
1193
1310
 
1194
1311
  # Planet grids
@@ -1214,8 +1331,8 @@ class KerykeionChartSVG:
1214
1331
 
1215
1332
  # House comparison grid
1216
1333
  house_comparison_factory = HouseComparisonFactory(
1217
- first_subject=self.first_obj,
1218
- second_subject=self.second_obj,
1334
+ first_subject=self.first_obj, # type: ignore[arg-type]
1335
+ second_subject=self.second_obj, # type: ignore[arg-type]
1219
1336
  active_points=self.active_points,
1220
1337
  )
1221
1338
  house_comparison = house_comparison_factory.get_house_comparison()
@@ -1228,12 +1345,12 @@ class KerykeionChartSVG:
1228
1345
  house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1229
1346
  return_point_label=self.language_settings.get("transit_point", "Transit Point"),
1230
1347
  natal_house_label=self.language_settings.get("house_position", "Natal House"),
1231
- x_position=930,
1348
+ x_position=980,
1232
1349
  )
1233
1350
 
1234
1351
  elif self.chart_type == "Synastry":
1235
- # Set viewbox
1236
- template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1352
+ # Set viewbox dynamically
1353
+ template_dict["viewbox"] = self._dynamic_viewbox()
1237
1354
 
1238
1355
  # Get houses list for secondary subject
1239
1356
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1273,10 +1390,10 @@ class KerykeionChartSVG:
1273
1390
  if self.double_chart_aspect_grid_type == "list":
1274
1391
  template_dict["makeAspectGrid"] = ""
1275
1392
  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
1393
+ f"{self.first_obj.name} - {self.second_obj.name} {self.language_settings.get('synastry_aspects', 'Synastry Aspects')}", # type: ignore[union-attr]
1277
1394
  self.aspects_list,
1278
- self.planets_settings,
1279
- self.aspects_settings
1395
+ self.planets_settings, # type: ignore[arg-type]
1396
+ self.aspects_settings # type: ignore[arg-type]
1280
1397
  )
1281
1398
  else:
1282
1399
  template_dict["makeAspectGrid"] = ""
@@ -1290,9 +1407,6 @@ class KerykeionChartSVG:
1290
1407
 
1291
1408
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1292
1409
 
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
1410
  # Top left section
1297
1411
  template_dict["top_left_0"] = f"{self.first_obj.name}:"
1298
1412
  template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore
@@ -1343,6 +1457,7 @@ class KerykeionChartSVG:
1343
1457
  c1=self.first_circle_radius,
1344
1458
  c3=self.third_circle_radius,
1345
1459
  chart_type=self.chart_type,
1460
+ external_view=self.external_view,
1346
1461
  second_subject_houses_list=second_subject_houses_list,
1347
1462
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1348
1463
  )
@@ -1356,6 +1471,7 @@ class KerykeionChartSVG:
1356
1471
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1357
1472
  chart_type=self.chart_type,
1358
1473
  third_circle_radius=self.third_circle_radius,
1474
+ external_view=self.external_view,
1359
1475
  )
1360
1476
 
1361
1477
  # Planet grid
@@ -1377,9 +1493,9 @@ class KerykeionChartSVG:
1377
1493
  )
1378
1494
  template_dict["makeHouseComparisonGrid"] = ""
1379
1495
 
1380
- elif self.chart_type == "Return":
1381
- # Set viewbox
1382
- template_dict["viewbox"] = self._ULTRA_WIDE_CHART_VIEWBOX
1496
+ elif self.chart_type == "DualReturnChart":
1497
+ # Set viewbox dynamically
1498
+ template_dict["viewbox"] = self._dynamic_viewbox()
1383
1499
 
1384
1500
  # Get houses list for secondary subject
1385
1501
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1419,7 +1535,7 @@ class KerykeionChartSVG:
1419
1535
  if self.double_chart_aspect_grid_type == "list":
1420
1536
  title = self.language_settings.get("return_aspects", "Natal to Return Aspects")
1421
1537
  template_dict["makeAspectGrid"] = ""
1422
- template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings, max_columns=7)
1538
+ 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
1539
  else:
1424
1540
  template_dict["makeAspectGrid"] = ""
1425
1541
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
@@ -1432,12 +1548,6 @@ class KerykeionChartSVG:
1432
1548
 
1433
1549
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1434
1550
 
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
1551
 
1442
1552
  # Top left section
1443
1553
  # Subject
@@ -1509,6 +1619,7 @@ class KerykeionChartSVG:
1509
1619
  c1=self.first_circle_radius,
1510
1620
  c3=self.third_circle_radius,
1511
1621
  chart_type=self.chart_type,
1622
+ external_view=self.external_view,
1512
1623
  second_subject_houses_list=second_subject_houses_list,
1513
1624
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1514
1625
  )
@@ -1522,6 +1633,7 @@ class KerykeionChartSVG:
1522
1633
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1523
1634
  chart_type=self.chart_type,
1524
1635
  third_circle_radius=self.third_circle_radius,
1636
+ external_view=self.external_view,
1525
1637
  )
1526
1638
 
1527
1639
  # Planet grid
@@ -1549,8 +1661,8 @@ class KerykeionChartSVG:
1549
1661
  )
1550
1662
 
1551
1663
  house_comparison_factory = HouseComparisonFactory(
1552
- first_subject=self.first_obj,
1553
- second_subject=self.second_obj,
1664
+ first_subject=self.first_obj, # type: ignore[arg-type]
1665
+ second_subject=self.second_obj, # type: ignore[arg-type]
1554
1666
  active_points=self.active_points,
1555
1667
  )
1556
1668
  house_comparison = house_comparison_factory.get_house_comparison()
@@ -1562,13 +1674,13 @@ class KerykeionChartSVG:
1562
1674
  points_owner_subject_number=2, # The second subject is the Solar Return
1563
1675
  house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1564
1676
  return_point_label=self.language_settings.get("return_point", "Return Point"),
1565
- return_label=self.language_settings.get("Return", "Return"),
1677
+ return_label=self.language_settings.get("Return", "DualReturnChart"),
1566
1678
  radix_label=self.language_settings.get("Natal", "Natal"),
1567
1679
  )
1568
1680
 
1569
- elif self.chart_type == "SingleWheelReturn":
1570
- # Set viewbox
1571
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1681
+ elif self.chart_type == "SingleReturnChart":
1682
+ # Set viewbox dynamically
1683
+ template_dict["viewbox"] = self._dynamic_viewbox()
1572
1684
 
1573
1685
  # Rings and circles
1574
1686
  template_dict["transitRing"] = ""
@@ -1613,9 +1725,6 @@ class KerykeionChartSVG:
1613
1725
  )
1614
1726
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1615
1727
 
1616
- # Chart title
1617
- template_dict["stringTitle"] = self.first_obj.name
1618
-
1619
1728
  # Top left section
1620
1729
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1621
1730
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -1677,6 +1786,7 @@ class KerykeionChartSVG:
1677
1786
  c1=self.first_circle_radius,
1678
1787
  c3=self.third_circle_radius,
1679
1788
  chart_type=self.chart_type,
1789
+ external_view=self.external_view,
1680
1790
  )
1681
1791
 
1682
1792
  template_dict["makePlanets"] = draw_planets(
@@ -1687,6 +1797,7 @@ class KerykeionChartSVG:
1687
1797
  third_circle_radius=self.third_circle_radius,
1688
1798
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1689
1799
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1800
+ external_view=self.external_view,
1690
1801
  )
1691
1802
 
1692
1803
  template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
@@ -1700,9 +1811,9 @@ class KerykeionChartSVG:
1700
1811
  template_dict["makeSecondaryPlanetGrid"] = ""
1701
1812
  template_dict["makeHouseComparisonGrid"] = ""
1702
1813
 
1703
- return ChartTemplateDictionary(**template_dict)
1814
+ return ChartTemplateModel(**template_dict)
1704
1815
 
1705
- def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str:
1816
+ def generate_svg_string(self, minify: bool = False, remove_css_variables=False) -> str:
1706
1817
  """
1707
1818
  Render the full chart SVG as a string.
1708
1819
 
@@ -1723,11 +1834,11 @@ class KerykeionChartSVG:
1723
1834
 
1724
1835
  # read template
1725
1836
  with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
1726
- template = Template(f.read()).substitute(td)
1837
+ template = Template(f.read()).substitute(td.model_dump())
1727
1838
 
1728
1839
  # return filename
1729
1840
 
1730
- logging.debug(f"Template dictionary keys: {td.keys()}")
1841
+ logging.debug(f"Template dictionary has {len(td.model_dump())} fields")
1731
1842
 
1732
1843
  self._create_template_dictionary()
1733
1844
 
@@ -1742,36 +1853,50 @@ class KerykeionChartSVG:
1742
1853
 
1743
1854
  return template
1744
1855
 
1745
- def makeSVG(self, minify: bool = False, remove_css_variables=False):
1856
+ def save_svg(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
1746
1857
  """
1747
1858
  Generate and save the full chart SVG to disk.
1748
1859
 
1749
- Calls makeTemplate to render the SVG, then writes a file named
1750
- "{subject.name} - {chart_type} Chart.svg" in the output directory.
1860
+ Calls generate_svg_string to render the SVG, then writes a file named
1861
+ "{subject.name} - {chart_type} Chart.svg" in the specified output directory.
1751
1862
 
1752
1863
  Args:
1753
- minify (bool): Pass-through to makeTemplate for compact output.
1754
- remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
1864
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
1865
+ If None, defaults to the user's home directory.
1866
+ filename (str or None): Custom filename for the SVG file (without extension).
1867
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart".
1868
+ minify (bool): Pass-through to generate_svg_string for compact output.
1869
+ remove_css_variables (bool): Pass-through to generate_svg_string to embed CSS variables.
1755
1870
 
1756
1871
  Returns:
1757
1872
  None
1758
1873
  """
1759
1874
 
1760
- self.template = self.makeTemplate(minify, remove_css_variables)
1875
+ self.template = self.generate_svg_string(minify, remove_css_variables)
1761
1876
 
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"
1877
+ # Convert output_path to Path object, default to home directory
1878
+ output_directory = Path(output_path) if output_path is not None else Path.home()
1879
+
1880
+ # Determine filename
1881
+ if filename is not None:
1882
+ chartname = output_directory / f"{filename}.svg"
1766
1883
  else:
1767
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart.svg"
1884
+ # Use default filename pattern
1885
+ chart_type_for_filename = self.chart_type
1886
+
1887
+ 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":
1888
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Lunar Return.svg"
1889
+ 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":
1890
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Solar Return.svg"
1891
+ else:
1892
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart.svg"
1768
1893
 
1769
1894
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1770
1895
  output_file.write(self.template)
1771
1896
 
1772
1897
  print(f"SVG Generated Correctly in: {chartname}")
1773
1898
 
1774
- def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1899
+ def generate_wheel_only_svg_string(self, minify: bool = False, remove_css_variables=False):
1775
1900
  """
1776
1901
  Render the wheel-only chart SVG as a string.
1777
1902
 
@@ -1795,7 +1920,9 @@ class KerykeionChartSVG:
1795
1920
  template = f.read()
1796
1921
 
1797
1922
  template_dict = self._create_template_dictionary()
1798
- template = Template(template).substitute(template_dict)
1923
+ # Use a compact viewBox specific for the wheel-only rendering
1924
+ wheel_viewbox = self._wheel_only_viewbox()
1925
+ template = Template(template).substitute({**template_dict.model_dump(), "viewbox": wheel_viewbox})
1799
1926
 
1800
1927
  if remove_css_variables:
1801
1928
  template = inline_css_variables_in_svg(template)
@@ -1808,30 +1935,44 @@ class KerykeionChartSVG:
1808
1935
 
1809
1936
  return template
1810
1937
 
1811
- def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False):
1938
+ 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
1939
  """
1813
1940
  Generate and save wheel-only chart SVG to disk.
1814
1941
 
1815
- Calls makeWheelOnlyTemplate and writes a file named
1816
- "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
1942
+ Calls generate_wheel_only_svg_string and writes a file named
1943
+ "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the specified output directory.
1817
1944
 
1818
1945
  Args:
1819
- minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
1820
- remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
1946
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
1947
+ If None, defaults to the user's home directory.
1948
+ filename (str or None): Custom filename for the SVG file (without extension).
1949
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Wheel Only".
1950
+ minify (bool): Pass-through to generate_wheel_only_svg_string for compact output.
1951
+ remove_css_variables (bool): Pass-through to generate_wheel_only_svg_string to embed CSS variables.
1821
1952
 
1822
1953
  Returns:
1823
1954
  None
1824
1955
  """
1825
1956
 
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"
1957
+ template = self.generate_wheel_only_svg_string(minify, remove_css_variables)
1958
+
1959
+ # Convert output_path to Path object, default to home directory
1960
+ output_directory = Path(output_path) if output_path is not None else Path.home()
1961
+
1962
+ # Determine filename
1963
+ if filename is not None:
1964
+ chartname = output_directory / f"{filename}.svg"
1965
+ else:
1966
+ # Use default filename pattern
1967
+ chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
1968
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Wheel Only.svg"
1828
1969
 
1829
1970
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1830
1971
  output_file.write(template)
1831
1972
 
1832
1973
  print(f"SVG Generated Correctly in: {chartname}")
1833
1974
 
1834
- def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1975
+ def generate_aspect_grid_only_svg_string(self, minify: bool = False, remove_css_variables=False):
1835
1976
  """
1836
1977
  Render the aspect-grid-only chart SVG as a string.
1837
1978
 
@@ -1856,7 +1997,7 @@ class KerykeionChartSVG:
1856
1997
 
1857
1998
  template_dict = self._create_template_dictionary()
1858
1999
 
1859
- if self.chart_type in ["Transit", "Synastry", "Return"]:
2000
+ if self.chart_type in ["Transit", "Synastry", "DualReturnChart"]:
1860
2001
  aspects_grid = draw_transit_aspect_grid(
1861
2002
  self.chart_colors_settings["paper_0"],
1862
2003
  self.available_planets_setting,
@@ -1871,7 +2012,10 @@ class KerykeionChartSVG:
1871
2012
  y_start=250,
1872
2013
  )
1873
2014
 
1874
- template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
2015
+ # Use a compact, known-good viewBox that frames the grid
2016
+ viewbox_override = self._grid_only_viewbox()
2017
+
2018
+ template = Template(template).substitute({**template_dict.model_dump(), "makeAspectGrid": aspects_grid, "viewbox": viewbox_override})
1875
2019
 
1876
2020
  if remove_css_variables:
1877
2021
  template = inline_css_variables_in_svg(template)
@@ -1884,42 +2028,55 @@ class KerykeionChartSVG:
1884
2028
 
1885
2029
  return template
1886
2030
 
1887
- def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False):
2031
+ 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
2032
  """
1889
2033
  Generate and save aspect-grid-only chart SVG to disk.
1890
2034
 
1891
- Calls makeAspectGridOnlyTemplate and writes a file named
1892
- "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
2035
+ Calls generate_aspect_grid_only_svg_string and writes a file named
2036
+ "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the specified output directory.
1893
2037
 
1894
2038
  Args:
1895
- minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
1896
- remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
2039
+ output_path (str, Path, or None): Directory path where the SVG file will be saved.
2040
+ If None, defaults to the user's home directory.
2041
+ filename (str or None): Custom filename for the SVG file (without extension).
2042
+ If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Aspect Grid Only".
2043
+ minify (bool): Pass-through to generate_aspect_grid_only_svg_string for compact output.
2044
+ remove_css_variables (bool): Pass-through to generate_aspect_grid_only_svg_string to embed CSS variables.
1897
2045
 
1898
2046
  Returns:
1899
2047
  None
1900
2048
  """
1901
2049
 
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"
2050
+ template = self.generate_aspect_grid_only_svg_string(minify, remove_css_variables)
2051
+
2052
+ # Convert output_path to Path object, default to home directory
2053
+ output_directory = Path(output_path) if output_path is not None else Path.home()
2054
+
2055
+ # Determine filename
2056
+ if filename is not None:
2057
+ chartname = output_directory / f"{filename}.svg"
2058
+ else:
2059
+ # Use default filename pattern
2060
+ chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2061
+ chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Aspect Grid Only.svg"
1904
2062
 
1905
2063
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1906
2064
  output_file.write(template)
1907
2065
 
1908
2066
  print(f"SVG Generated Correctly in: {chartname}")
1909
2067
 
1910
-
1911
2068
  if __name__ == "__main__":
1912
2069
  from kerykeion.utilities import setup_logging
1913
2070
  from kerykeion.planetary_return_factory import PlanetaryReturnFactory
1914
2071
  from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
2072
+ from kerykeion.chart_data_factory import ChartDataFactory
2073
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
1915
2074
 
1916
- ACTIVE_PLANETS: list[AstrologicalPoint] = [
1917
- "Sun", "Moon", "Pars_Fortunae", "Mercury", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", "Chiron", "True_Node"
1918
- ]
1919
-
2075
+ ACTIVE_PLANETS: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS
2076
+ # ACTIVE_PLANETS: list[AstrologicalPoint] = ALL_ACTIVE_POINTS
1920
2077
  setup_logging(level="info")
1921
2078
 
1922
- subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
2079
+ subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB", active_points=ACTIVE_PLANETS)
1923
2080
 
1924
2081
  return_factory = PlanetaryReturnFactory(
1925
2082
  subject,
@@ -1932,101 +2089,118 @@ if __name__ == "__main__":
1932
2089
  )
1933
2090
 
1934
2091
  ###
1935
- ## Birth Chart
1936
- birth_chart = KerykeionChartSVG(
1937
- first_obj=subject,
2092
+ ## Birth Chart - NEW APPROACH with ChartDataFactory
2093
+ birth_chart_data = ChartDataFactory.create_natal_chart_data(
2094
+ subject,
2095
+ active_points=ACTIVE_PLANETS,
2096
+ )
2097
+ birth_chart = ChartDrawer(
2098
+ chart_data=birth_chart_data,
1938
2099
  chart_language="IT",
1939
2100
  theme="strawberry",
1940
- active_points=ACTIVE_PLANETS,
1941
2101
  )
1942
- birth_chart.makeSVG() # minify=True, remove_css_variables=True)
2102
+ birth_chart.save_svg() # minify=True, remove_css_variables=True)
1943
2103
 
1944
2104
  ###
1945
- ## Solar Return Chart
2105
+ ## Solar Return Chart - NEW APPROACH with ChartDataFactory
1946
2106
  solar_return = return_factory.next_return_from_iso_formatted_time(
1947
2107
  "2025-01-09T18:30:00+01:00", # UTC+1
1948
2108
  return_type="Solar",
1949
2109
  )
1950
- solar_return_chart = KerykeionChartSVG(
1951
- first_obj=subject, chart_type="Return",
1952
- second_obj=solar_return,
2110
+ solar_return_chart_data = ChartDataFactory.create_return_chart_data(
2111
+ subject,
2112
+ solar_return,
2113
+ active_points=ACTIVE_PLANETS,
2114
+ )
2115
+ solar_return_chart = ChartDrawer(
2116
+ chart_data=solar_return_chart_data,
1953
2117
  chart_language="IT",
1954
2118
  theme="classic",
1955
- active_points=ACTIVE_PLANETS,
1956
2119
  )
1957
2120
 
1958
- solar_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2121
+ solar_return_chart.save_svg() # minify=True, remove_css_variables=True)
1959
2122
 
1960
2123
  ###
1961
- ## Single wheel return
1962
- single_wheel_return_chart = KerykeionChartSVG(
1963
- first_obj=solar_return,
1964
- chart_type="SingleWheelReturn",
1965
- second_obj=solar_return,
2124
+ ## Single wheel return - NEW APPROACH with ChartDataFactory
2125
+ single_wheel_return_chart_data = ChartDataFactory.create_single_wheel_return_chart_data(
2126
+ solar_return,
2127
+ active_points=ACTIVE_PLANETS,
2128
+ )
2129
+ single_wheel_return_chart = ChartDrawer(
2130
+ chart_data=single_wheel_return_chart_data,
1966
2131
  chart_language="IT",
1967
2132
  theme="dark",
1968
- active_points=ACTIVE_PLANETS,
1969
2133
  )
1970
2134
 
1971
- single_wheel_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2135
+ single_wheel_return_chart.save_svg() # minify=True, remove_css_variables=True)
1972
2136
 
1973
2137
  ###
1974
- ## Lunar return
2138
+ ## Lunar return - NEW APPROACH with ChartDataFactory
1975
2139
  lunar_return = return_factory.next_return_from_iso_formatted_time(
1976
2140
  "2025-01-09T18:30:00+01:00", # UTC+1
1977
2141
  return_type="Lunar",
1978
2142
  )
1979
- lunar_return_chart = KerykeionChartSVG(
1980
- first_obj=subject,
1981
- chart_type="Return",
1982
- second_obj=lunar_return,
2143
+ lunar_return_chart_data = ChartDataFactory.create_return_chart_data(
2144
+ subject,
2145
+ lunar_return,
2146
+ active_points=ACTIVE_PLANETS,
2147
+ )
2148
+ lunar_return_chart = ChartDrawer(
2149
+ chart_data=lunar_return_chart_data,
1983
2150
  chart_language="IT",
1984
2151
  theme="dark",
1985
- active_points=ACTIVE_PLANETS,
1986
2152
  )
1987
- lunar_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2153
+ lunar_return_chart.save_svg() # minify=True, remove_css_variables=True)
1988
2154
 
1989
2155
  ###
1990
- ## Transit Chart
2156
+ ## Transit Chart - NEW APPROACH with ChartDataFactory
1991
2157
  transit = AstrologicalSubjectFactory.from_iso_utc_time(
1992
2158
  "Transit",
1993
2159
  "2021-10-04T18:30:00+01:00",
1994
2160
  )
1995
- transit_chart = KerykeionChartSVG(
1996
- first_obj=subject,
1997
- chart_type="Transit",
1998
- second_obj=transit,
2161
+ transit_chart_data = ChartDataFactory.create_transit_chart_data(
2162
+ subject,
2163
+ transit,
2164
+ active_points=ACTIVE_PLANETS,
2165
+ )
2166
+ transit_chart = ChartDrawer(
2167
+ chart_data=transit_chart_data,
1999
2168
  chart_language="IT",
2000
2169
  theme="dark",
2001
- active_points=ACTIVE_PLANETS
2002
2170
  )
2003
- transit_chart.makeSVG() # minify=True, remove_css_variables=True)
2171
+ transit_chart.save_svg() # minify=True, remove_css_variables=True)
2004
2172
 
2005
2173
  ###
2006
- ## Synastry Chart
2174
+ ## Synastry Chart - NEW APPROACH with ChartDataFactory
2007
2175
  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,
2176
+ synastry_chart_data = ChartDataFactory.create_synastry_chart_data(
2177
+ subject,
2178
+ second_subject,
2179
+ active_points=ACTIVE_PLANETS,
2180
+ )
2181
+ synastry_chart = ChartDrawer(
2182
+ chart_data=synastry_chart_data,
2012
2183
  chart_language="IT",
2013
2184
  theme="dark",
2014
- active_points=ACTIVE_PLANETS
2015
2185
  )
2016
- synastry_chart.makeSVG() # minify=True, remove_css_variables=True)
2186
+ synastry_chart.save_svg() # minify=True, remove_css_variables=True)
2017
2187
 
2018
2188
  ##
2019
- # Transit Chart with Grid
2189
+ # Transit Chart with Grid - NEW APPROACH with ChartDataFactory
2020
2190
  subject.name = "Grid"
2021
- transit_chart_with_grid = KerykeionChartSVG(
2022
- first_obj=subject,
2023
- chart_type="Transit",
2024
- second_obj=transit,
2191
+ transit_chart_with_grid_data = ChartDataFactory.create_transit_chart_data(
2192
+ subject,
2193
+ transit,
2194
+ active_points=ACTIVE_PLANETS,
2195
+ )
2196
+ transit_chart_with_grid = ChartDrawer(
2197
+ chart_data=transit_chart_with_grid_data,
2025
2198
  chart_language="IT",
2026
2199
  theme="dark",
2027
- active_points=ACTIVE_PLANETS,
2028
2200
  double_chart_aspect_grid_type="table"
2029
2201
  )
2030
- transit_chart_with_grid.makeSVG() # minify=True, remove_css_variables=True)
2031
- transit_chart_with_grid.makeAspectGridOnlySVG()
2032
- transit_chart_with_grid.makeWheelOnlySVG()
2202
+ transit_chart_with_grid.save_svg() # minify=True, remove_css_variables=True)
2203
+ transit_chart_with_grid.save_aspect_grid_only_svg_file()
2204
+ transit_chart_with_grid.save_wheel_only_svg_file()
2205
+
2206
+ print("✅ All chart examples completed using ChartDataFactory + ChartDrawer architecture!")