kerykeion 5.0.0a11__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 (56) hide show
  1. kerykeion/__init__.py +32 -9
  2. kerykeion/aspects/__init__.py +2 -4
  3. kerykeion/aspects/aspects_factory.py +530 -0
  4. kerykeion/aspects/aspects_utils.py +75 -6
  5. kerykeion/astrological_subject_factory.py +380 -229
  6. kerykeion/backword.py +680 -0
  7. kerykeion/chart_data_factory.py +484 -0
  8. kerykeion/charts/{kerykeion_chart_svg.py → chart_drawer.py} +612 -439
  9. kerykeion/charts/charts_utils.py +135 -94
  10. kerykeion/charts/draw_planets.py +38 -28
  11. kerykeion/charts/templates/aspect_grid_only.xml +188 -17
  12. kerykeion/charts/templates/chart.xml +104 -28
  13. kerykeion/charts/templates/wheel_only.xml +195 -24
  14. kerykeion/charts/themes/classic.css +11 -0
  15. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  16. kerykeion/charts/themes/dark.css +11 -0
  17. kerykeion/charts/themes/light.css +11 -0
  18. kerykeion/charts/themes/strawberry.css +10 -0
  19. kerykeion/composite_subject_factory.py +4 -4
  20. kerykeion/ephemeris_data_factory.py +12 -9
  21. kerykeion/house_comparison/__init__.py +0 -3
  22. kerykeion/house_comparison/house_comparison_factory.py +51 -18
  23. kerykeion/house_comparison/house_comparison_utils.py +37 -8
  24. kerykeion/planetary_return_factory.py +8 -4
  25. kerykeion/relationship_score_factory.py +5 -5
  26. kerykeion/report.py +748 -67
  27. kerykeion/{kr_types → schemas}/__init__.py +44 -4
  28. kerykeion/schemas/chart_template_model.py +340 -0
  29. kerykeion/{kr_types → schemas}/kr_literals.py +7 -3
  30. kerykeion/{kr_types → schemas}/kr_models.py +247 -21
  31. kerykeion/{kr_types → schemas}/settings_models.py +7 -7
  32. kerykeion/settings/config_constants.py +75 -8
  33. kerykeion/settings/kerykeion_settings.py +1 -1
  34. kerykeion/settings/kr.config.json +130 -40
  35. kerykeion/settings/legacy/legacy_celestial_points_settings.py +8 -8
  36. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  37. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  38. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  39. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  40. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  41. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  42. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  43. kerykeion/sweph/sefstars.txt +1602 -0
  44. kerykeion/transits_time_range_factory.py +11 -11
  45. kerykeion/utilities.py +61 -38
  46. kerykeion-5.0.0b1.dist-info/METADATA +1055 -0
  47. kerykeion-5.0.0b1.dist-info/RECORD +58 -0
  48. kerykeion/aspects/natal_aspects_factory.py +0 -235
  49. kerykeion/aspects/synastry_aspects_factory.py +0 -275
  50. kerykeion/house_comparison/house_comparison_models.py +0 -38
  51. kerykeion/kr_types/chart_types.py +0 -106
  52. kerykeion-5.0.0a11.dist-info/METADATA +0 -641
  53. kerykeion-5.0.0a11.dist-info/RECORD +0 -50
  54. /kerykeion/{kr_types → schemas}/kerykeion_exception.py +0 -0
  55. {kerykeion-5.0.0a11.dist-info → kerykeion-5.0.0b1.dist-info}/WHEEL +0 -0
  56. {kerykeion-5.0.0a11.dist-info → kerykeion-5.0.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -6,34 +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.synastry_aspects_factory import SynastryAspectsFactory
13
- from kerykeion.aspects.natal_aspects_factory import NatalAspectsFactory
14
15
  from kerykeion.house_comparison.house_comparison_factory import HouseComparisonFactory
15
- from kerykeion.kr_types import (
16
+ from kerykeion.schemas import (
16
17
  KerykeionException,
17
18
  ChartType,
18
19
  Sign,
19
20
  ActiveAspect,
20
21
  )
21
- from kerykeion.kr_types import ChartTemplateDictionary
22
- from kerykeion.kr_types.kr_models import (
22
+ from kerykeion.schemas import ChartTemplateModel
23
+ from kerykeion.schemas.kr_models import (
23
24
  AstrologicalSubjectModel,
24
25
  CompositeSubjectModel,
25
26
  PlanetReturnModel,
26
27
  )
27
- from kerykeion.kr_types.settings_models import (
28
+ from kerykeion.schemas.settings_models import (
28
29
  KerykeionSettingsCelestialPointModel,
29
30
  KerykeionSettingsModel,
30
31
  )
31
- from kerykeion.kr_types.kr_literals import (
32
+ from kerykeion.schemas.kr_literals import (
32
33
  KerykeionChartTheme,
33
34
  KerykeionChartLanguage,
34
35
  AstrologicalPoint,
35
36
  )
36
- from kerykeion.utilities import find_common_active_points
37
37
  from kerykeion.charts.charts_utils import (
38
38
  draw_zodiac_slice,
39
39
  convert_latitude_coordinate_to_string,
@@ -58,15 +58,10 @@ from kerykeion.charts.charts_utils import (
58
58
  draw_main_planet_grid,
59
59
  draw_secondary_planet_grid,
60
60
  format_location_string,
61
- format_datetime_with_timezone,
62
- calculate_element_points,
63
- calculate_synastry_element_points,
64
- calculate_quality_points,
65
- calculate_synastry_quality_points
61
+ format_datetime_with_timezone
66
62
  )
67
63
  from kerykeion.charts.draw_planets import draw_planets
68
- from kerykeion.utilities import get_houses_list, inline_css_variables_in_svg
69
- 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
70
65
  from kerykeion.settings.legacy.legacy_color_settings import DEFAULT_CHART_COLORS
71
66
  from kerykeion.settings.legacy.legacy_celestial_points_settings import DEFAULT_CELESTIAL_POINTS_SETTINGS
72
67
  from kerykeion.settings.legacy.legacy_chart_aspects_settings import DEFAULT_CHART_ASPECTS_SETTINGS
@@ -77,30 +72,32 @@ from typing import List, Literal
77
72
  from datetime import datetime
78
73
 
79
74
 
80
- class KerykeionChartSVG:
75
+ class ChartDrawer:
81
76
  """
82
- 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.
83
83
 
84
- This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
85
- 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.
86
86
  Charts are rendered using XML templates and drawing utilities, with customizable themes,
87
- language, active points, and aspects.
88
- 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.
89
91
 
90
92
  NOTE:
91
93
  The generated SVG files are optimized for web use, opening in browsers. If you want to
92
94
  use them in other applications, you might need to adjust the SVG settings or styles.
93
95
 
94
96
  Args:
95
- first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel):
96
- The primary astrological subject for the chart.
97
- chart_type (ChartType, optional):
98
- The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite').
99
- Defaults to 'Natal'.
100
- second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional):
101
- The secondary subject for Transit or Synastry charts. Not required for Natal or Composite.
102
- new_output_directory (str | Path, optional):
103
- 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.
104
101
  new_settings_file (Path | dict | KerykeionSettingsModel, optional):
105
102
  Path or settings object to override default chart configuration (colors, fonts, aspects).
106
103
  theme (KerykeionChartTheme, optional):
@@ -109,55 +106,60 @@ class KerykeionChartSVG:
109
106
  Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
110
107
  chart_language (KerykeionChartLanguage, optional):
111
108
  Language code for chart labels. Defaults to 'EN'.
112
- active_points (list[AstrologicalPoint], optional):
113
- List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS.
114
- Example:
115
- ["Sun", "Moon", "Mercury", "Venus"]
116
-
117
- active_aspects (list[ActiveAspect], optional):
118
- List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
119
- Example:
120
- [
121
- {"name": "conjunction", "orb": 10},
122
- {"name": "opposition", "orb": 10},
123
- {"name": "trine", "orb": 8},
124
- {"name": "sextile", "orb": 6},
125
- {"name": "square", "orb": 5},
126
- {"name": "quintile", "orb": 1},
127
- ]
109
+ transparent_background (bool, optional):
110
+ Whether to use a transparent background instead of the theme color. Defaults to False.
128
111
 
129
112
  Public Methods:
130
113
  makeTemplate(minify=False, remove_css_variables=False) -> str:
131
114
  Render the full chart SVG as a string without writing to disk. Use `minify=True`
132
115
  to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
133
116
 
134
- makeSVG(minify=False, remove_css_variables=False) -> None:
135
- Generate and write the full chart SVG file to the output directory.
136
- Filenames follow the pattern:
137
- '{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'.
138
121
 
139
122
  makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
140
123
  Render only the chart wheel (no aspect grid) as an SVG string.
141
124
 
142
- makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
143
- Generate and write the wheel-only SVG file:
144
- '{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'.
145
129
 
146
130
  makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
147
131
  Render only the aspect grid as an SVG string.
148
132
 
149
- makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
150
- Generate and write the aspect-grid-only SVG file:
151
- '{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")
152
154
  """
153
155
 
154
156
  # Constants
155
157
 
156
158
  _DEFAULT_HEIGHT = 550
157
- _DEFAULT_FULL_WIDTH = 1200
159
+ _DEFAULT_FULL_WIDTH = 1250
158
160
  _DEFAULT_NATAL_WIDTH = 870
159
- _DEFAULT_FULL_WIDTH_WITH_TABLE = 1200
160
- _DEFAULT_ULTRA_WIDE_WIDTH = 1270
161
+ _DEFAULT_FULL_WIDTH_WITH_TABLE = 1250
162
+ _DEFAULT_ULTRA_WIDE_WIDTH = 1320
161
163
 
162
164
  _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}"
163
165
  _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0"
@@ -168,9 +170,6 @@ class KerykeionChartSVG:
168
170
  first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
169
171
  second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None]
170
172
  chart_type: ChartType
171
- new_output_directory: Union[Path, None]
172
- new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
173
- output_directory: Path
174
173
  new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
175
174
  theme: Union[KerykeionChartTheme, None]
176
175
  double_chart_aspect_grid_type: Literal["list", "table"]
@@ -178,6 +177,8 @@ class KerykeionChartSVG:
178
177
  active_points: List[AstrologicalPoint]
179
178
  active_aspects: List[ActiveAspect]
180
179
  transparent_background: bool
180
+ external_view: bool
181
+ custom_title: Union[str, None]
181
182
 
182
183
  # Internal properties
183
184
  fire: float
@@ -190,8 +191,8 @@ class KerykeionChartSVG:
190
191
  width: Union[float, int]
191
192
  language_settings: dict
192
193
  chart_colors_settings: dict
193
- planets_settings: dict
194
- aspects_settings: dict
194
+ planets_settings: list[dict[Any, Any]]
195
+ aspects_settings: list[dict[Any, Any]]
195
196
  available_planets_setting: List[KerykeionSettingsCelestialPointModel]
196
197
  height: float
197
198
  location: str
@@ -201,34 +202,28 @@ class KerykeionChartSVG:
201
202
 
202
203
  def __init__(
203
204
  self,
204
- first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
205
- chart_type: ChartType = "Natal",
206
- second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None] = None,
207
- new_output_directory: Union[str, None] = None,
205
+ chart_data: "ChartDataModel",
206
+ *,
208
207
  new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
209
208
  theme: Union[KerykeionChartTheme, None] = "classic",
210
209
  double_chart_aspect_grid_type: Literal["list", "table"] = "list",
211
210
  chart_language: KerykeionChartLanguage = "EN",
212
- active_points: Optional[list[AstrologicalPoint]] = None,
213
- active_aspects: list[ActiveAspect]= DEFAULT_ACTIVE_ASPECTS,
214
- *,
211
+ external_view: bool = False,
215
212
  transparent_background: bool = False,
216
213
  colors_settings: dict = DEFAULT_CHART_COLORS,
217
214
  celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
218
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,
219
219
  ):
220
220
  """
221
- Initialize the chart generator with subject data and configuration options.
221
+ Initialize the chart visualizer with pre-computed chart data.
222
222
 
223
223
  Args:
224
- first_obj (AstrologicalSubjectModel, or CompositeSubjectModel):
225
- Primary astrological subject instance.
226
- chart_type (ChartType, optional):
227
- Type of chart to generate (e.g., 'Natal', 'Transit').
228
- second_obj (AstrologicalSubject, optional):
229
- Secondary subject for Transit or Synastry charts.
230
- new_output_directory (str or Path, optional):
231
- 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.
232
227
  new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
233
228
  Custom settings source for chart colors, fonts, and aspects.
234
229
  theme (KerykeionChartTheme or None, optional):
@@ -237,99 +232,82 @@ class KerykeionChartSVG:
237
232
  Layout style for double-chart aspect grids ('list' or 'table').
238
233
  chart_language (KerykeionChartLanguage, optional):
239
234
  Language code for chart labels (e.g., 'EN', 'IT').
240
- active_points (List[AstrologicalPoint], optional):
241
- Celestial points to include in the chart visualization.
242
- active_aspects (List[ActiveAspect], optional):
243
- 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.
244
237
  transparent_background (bool, optional):
245
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.
246
241
  """
247
242
  # --------------------
248
243
  # COMMON INITIALIZATION
249
244
  # --------------------
250
- home_directory = Path.home()
251
245
  self.new_settings_file = new_settings_file
252
246
  self.chart_language = chart_language
253
- self.active_aspects = active_aspects
254
- self.chart_type = chart_type
255
247
  self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
256
248
  self.transparent_background = transparent_background
249
+ self.external_view = external_view
257
250
  self.chart_colors_settings = colors_settings
258
251
  self.planets_settings = celestial_points_settings
259
252
  self.aspects_settings = aspects_settings
260
-
261
- if not active_points:
262
- self.active_points = first_obj.active_points
263
- else:
264
- self.active_points = find_common_active_points(
265
- active_points,
266
- first_obj.active_points
267
- )
268
-
269
- if second_obj:
270
- self.active_points = find_common_active_points(
271
- self.active_points,
272
- second_obj.active_points
273
- )
274
-
275
- # Set output directory
276
- if new_output_directory:
277
- self.output_directory = Path(new_output_directory)
278
- else:
279
- 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')
280
271
 
281
272
  # Load settings
282
273
  self.parse_json_settings(new_settings_file)
283
274
 
284
- # Primary subject
285
- self.first_obj = first_obj
286
-
287
275
  # Default radius for all charts
288
276
  self.main_radius = 240
289
277
 
290
- # Configure available planets
278
+ # Configure available planets from chart data
291
279
  self.available_planets_setting = []
292
280
  for body in self.planets_settings:
293
281
  if body["name"] in self.active_points:
294
282
  body["is_active"] = True
295
- self.available_planets_setting.append(body)
283
+ self.available_planets_setting.append(body) # type: ignore[arg-type]
296
284
 
297
285
  # Set available celestial points
298
286
  available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
299
287
  self.available_kerykeion_celestial_points = []
300
288
  for body in available_celestial_points_names:
301
- 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]
302
291
 
303
292
  # ------------------------
304
- # CHART TYPE SPECIFIC SETUP
293
+ # CHART TYPE SPECIFIC SETUP FROM CHART DATA
305
294
  # ------------------------
306
295
 
307
- if self.chart_type in ["Natal", "ExternalNatal"]:
308
- # --- NATAL / EXTERNAL NATAL CHART SETUP ---
296
+ if self.chart_type == "Natal":
297
+ # --- NATAL CHART SETUP ---
309
298
 
310
- # Validate Subject
311
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
312
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
313
-
314
- # Calculate aspects
315
- natal_aspects_instance = NatalAspectsFactory.from_subject(
316
- self.first_obj,
317
- active_points=self.active_points,
318
- active_aspects=active_aspects,
319
- )
320
- self.aspects_list = natal_aspects_instance.relevant_aspects
299
+ # Extract aspects from pre-computed chart data
300
+ self.aspects_list = chart_data.aspects.relevant_aspects
321
301
 
322
302
  # Screen size
323
303
  self.height = self._DEFAULT_HEIGHT
324
304
  self.width = self._DEFAULT_NATAL_WIDTH
325
305
 
326
- # Location and coordinates
327
- self.location = self.first_obj.city
328
- self.geolat = self.first_obj.lat
329
- self.geolon = self.first_obj.lng
306
+ # Get location and coordinates
307
+ self.location, self.geolat, self.geolon = self._get_location_info()
330
308
 
331
- # Circle radii
332
- if self.chart_type == "ExternalNatal":
309
+ # Circle radii - depends on external_view
310
+ if self.external_view:
333
311
  self.first_circle_radius = 56
334
312
  self.second_circle_radius = 92
335
313
  self.third_circle_radius = 112
@@ -341,21 +319,15 @@ class KerykeionChartSVG:
341
319
  elif self.chart_type == "Composite":
342
320
  # --- COMPOSITE CHART SETUP ---
343
321
 
344
- # Validate Subject
345
- if not isinstance(self.first_obj, CompositeSubjectModel):
346
- raise KerykeionException("First object must be a CompositeSubjectModel instance.")
347
-
348
- # Calculate aspects
349
- self.aspects_list = NatalAspectsFactory.from_subject(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
350
324
 
351
325
  # Screen size
352
326
  self.height = self._DEFAULT_HEIGHT
353
327
  self.width = self._DEFAULT_NATAL_WIDTH
354
328
 
355
- # Location and coordinates (average of both subjects)
356
- self.location = ""
357
- self.geolat = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
358
- 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()
359
331
 
360
332
  # Circle radii
361
333
  self.first_circle_radius = 0
@@ -365,25 +337,8 @@ class KerykeionChartSVG:
365
337
  elif self.chart_type == "Transit":
366
338
  # --- TRANSIT CHART SETUP ---
367
339
 
368
- # Validate Subjects
369
- if not second_obj:
370
- raise KerykeionException("Second object is required for Transit charts.")
371
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
372
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
373
- if not isinstance(second_obj, AstrologicalSubjectModel):
374
- raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
375
-
376
- # Secondary subject setup
377
- self.second_obj = second_obj
378
-
379
- # Calculate aspects (transit to natal)
380
- synastry_aspects_instance = SynastryAspectsFactory.from_subjects(
381
- self.first_obj,
382
- self.second_obj,
383
- active_points=self.active_points,
384
- active_aspects=active_aspects,
385
- )
386
- 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
387
342
 
388
343
  # Secondary subject available points
389
344
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -395,11 +350,8 @@ class KerykeionChartSVG:
395
350
  else:
396
351
  self.width = self._DEFAULT_FULL_WIDTH
397
352
 
398
- # Location and coordinates (from transit subject)
399
- self.location = self.second_obj.city
400
- self.geolat = self.second_obj.lat
401
- self.geolon = self.second_obj.lng
402
- self.t_name = self.language_settings["transit_name"]
353
+ # Get location and coordinates
354
+ self.location, self.geolat, self.geolon = self._get_location_info()
403
355
 
404
356
  # Circle radii
405
357
  self.first_circle_radius = 0
@@ -409,25 +361,8 @@ class KerykeionChartSVG:
409
361
  elif self.chart_type == "Synastry":
410
362
  # --- SYNASTRY CHART SETUP ---
411
363
 
412
- # Validate Subjects
413
- if not second_obj:
414
- raise KerykeionException("Second object is required for Synastry charts.")
415
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
416
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
417
- if not isinstance(second_obj, AstrologicalSubjectModel):
418
- raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
419
-
420
- # Secondary subject setup
421
- self.second_obj = second_obj
422
-
423
- # Calculate aspects (natal to partner)
424
- synastry_aspects_instance = SynastryAspectsFactory.from_subjects(
425
- self.first_obj,
426
- self.second_obj,
427
- active_points=self.active_points,
428
- active_aspects=active_aspects,
429
- )
430
- 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
431
366
 
432
367
  # Secondary subject available points
433
368
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -436,38 +371,19 @@ class KerykeionChartSVG:
436
371
  self.height = self._DEFAULT_HEIGHT
437
372
  self.width = self._DEFAULT_FULL_WIDTH
438
373
 
439
- # Location and coordinates (from primary subject)
440
- self.location = self.first_obj.city
441
- self.geolat = self.first_obj.lat
442
- self.geolon = self.first_obj.lng
374
+ # Get location and coordinates
375
+ self.location, self.geolat, self.geolon = self._get_location_info()
443
376
 
444
377
  # Circle radii
445
378
  self.first_circle_radius = 0
446
379
  self.second_circle_radius = 36
447
380
  self.third_circle_radius = 120
448
381
 
449
- elif self.chart_type == "Return":
382
+ elif self.chart_type == "DualReturnChart":
450
383
  # --- RETURN CHART SETUP ---
451
384
 
452
- # Validate Subjects
453
- if not second_obj:
454
- raise KerykeionException("Second object is required for Return charts.")
455
- if not isinstance(self.first_obj, AstrologicalSubjectModel):
456
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
457
- if not isinstance(second_obj, PlanetReturnModel):
458
- raise KerykeionException("Second object must be a PlanetReturnModel instance.")
459
-
460
- # Secondary subject setup
461
- self.second_obj = second_obj
462
-
463
- # Calculate aspects (natal to return)
464
- synastry_aspects_instance = SynastryAspectsFactory.from_subjects(
465
- self.first_obj,
466
- self.second_obj,
467
- active_points=self.active_points,
468
- active_aspects=active_aspects,
469
- )
470
- 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
471
387
 
472
388
  # Secondary subject available points
473
389
  self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
@@ -476,93 +392,45 @@ class KerykeionChartSVG:
476
392
  self.height = self._DEFAULT_HEIGHT
477
393
  self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
478
394
 
479
- # Location and coordinates (from natal subject)
480
- self.location = self.first_obj.city
481
- self.geolat = self.first_obj.lat
482
- self.geolon = self.first_obj.lng
395
+ # Get location and coordinates
396
+ self.location, self.geolat, self.geolon = self._get_location_info()
483
397
 
484
398
  # Circle radii
485
399
  self.first_circle_radius = 0
486
400
  self.second_circle_radius = 36
487
401
  self.third_circle_radius = 120
488
402
 
489
- elif self.chart_type == "SingleWheelReturn":
490
- # --- NATAL / EXTERNAL NATAL CHART SETUP ---
491
-
492
- # Validate Subject
493
- if not isinstance(self.first_obj, PlanetReturnModel):
494
- raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
403
+ elif self.chart_type == "SingleReturnChart":
404
+ # --- SINGLE WHEEL RETURN CHART SETUP ---
495
405
 
496
- # Calculate aspects
497
- natal_aspects_instance = NatalAspectsFactory.from_subject(
498
- self.first_obj,
499
- active_points=self.active_points,
500
- active_aspects=active_aspects,
501
- )
502
- self.aspects_list = natal_aspects_instance.relevant_aspects
406
+ # Extract aspects from pre-computed chart data
407
+ self.aspects_list = chart_data.aspects.relevant_aspects
503
408
 
504
409
  # Screen size
505
410
  self.height = self._DEFAULT_HEIGHT
506
411
  self.width = self._DEFAULT_NATAL_WIDTH
507
412
 
508
- # Location and coordinates
509
- self.location = self.first_obj.city
510
- self.geolat = self.first_obj.lat
511
- self.geolon = self.first_obj.lng
413
+ # Get location and coordinates
414
+ self.location, self.geolat, self.geolon = self._get_location_info()
512
415
 
513
416
  # Circle radii
514
- if self.chart_type == "ExternalNatal":
515
- self.first_circle_radius = 56
516
- self.second_circle_radius = 92
517
- self.third_circle_radius = 112
518
- else:
519
- self.first_circle_radius = 0
520
- self.second_circle_radius = 36
521
- self.third_circle_radius = 120
417
+ self.first_circle_radius = 0
418
+ self.second_circle_radius = 36
419
+ self.third_circle_radius = 120
522
420
 
523
421
  # --------------------
524
- # FINAL COMMON SETUP
422
+ # FINAL COMMON SETUP FROM CHART DATA
525
423
  # --------------------
526
424
 
527
- # Calculate element points
528
- celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
529
- if self.chart_type == "Synastry":
530
- element_totals = calculate_synastry_element_points(
531
- self.available_planets_setting,
532
- celestial_points_names,
533
- self.first_obj,
534
- self.second_obj,
535
- )
536
- else:
537
- element_totals = calculate_element_points(
538
- self.available_planets_setting,
539
- celestial_points_names,
540
- self.first_obj,
541
- )
542
-
543
- self.fire = element_totals["fire"]
544
- self.earth = element_totals["earth"]
545
- self.air = element_totals["air"]
546
- 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
547
430
 
548
- # Calculate qualities points
549
- if self.chart_type == "Synastry":
550
- qualities_totals = calculate_synastry_quality_points(
551
- self.available_planets_setting,
552
- celestial_points_names,
553
- self.first_obj,
554
- self.second_obj,
555
- )
556
- else:
557
- qualities_totals = calculate_quality_points(
558
- self.available_planets_setting,
559
- celestial_points_names,
560
- self.first_obj,
561
- )
562
-
563
- self.cardinal = qualities_totals["cardinal"]
564
- self.fixed = qualities_totals["fixed"]
565
- 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
566
434
 
567
435
  # Set up theme
568
436
  if theme not in get_args(KerykeionChartTheme) and theme is not None:
@@ -570,6 +438,176 @@ class KerykeionChartSVG:
570
438
 
571
439
  self.set_up_theme(theme)
572
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
+
573
611
  def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
574
612
  """
575
613
  Load and apply a CSS theme for the chart visualization.
@@ -586,16 +624,6 @@ class KerykeionChartSVG:
586
624
  with open(theme_dir / f"{theme}.css", "r") as f:
587
625
  self.color_style_tag = f.read()
588
626
 
589
- def set_output_directory(self, dir_path: Path) -> None:
590
- """
591
- Set the directory where generated SVG files will be saved.
592
-
593
- Args:
594
- dir_path (Path): Target directory for SVG output.
595
- """
596
- self.output_directory = dir_path
597
- logging.info(f"Output directory set to: {self.output_directory}")
598
-
599
627
  def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
600
628
  """
601
629
  Load and parse chart configuration settings.
@@ -683,7 +711,89 @@ class KerykeionChartSVG:
683
711
  )
684
712
  return out
685
713
 
686
- 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:
687
797
  """
688
798
  Assemble chart data and rendering instructions into a template dictionary.
689
799
 
@@ -691,7 +801,7 @@ class KerykeionChartSVG:
691
801
  chart type and subjects.
692
802
 
693
803
  Returns:
694
- ChartTemplateDictionary: Populated structure of template variables.
804
+ ChartTemplateModel: Populated structure of template variables.
695
805
  """
696
806
  # Initialize template dictionary
697
807
  template_dict: dict = {}
@@ -707,7 +817,6 @@ class KerykeionChartSVG:
707
817
 
708
818
  # Set paper colors
709
819
  template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
710
- template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"]
711
820
 
712
821
  # Set background color based on transparent_background setting
713
822
  if self.transparent_background:
@@ -715,7 +824,12 @@ class KerykeionChartSVG:
715
824
  else:
716
825
  template_dict["background_color"] = self.chart_colors_settings["paper_1"]
717
826
 
718
- # 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
719
833
  for planet in self.planets_settings:
720
834
  planet_id = planet["id"]
721
835
  template_dict[f"planets_color_{planet_id}"] = planet["color"]
@@ -733,10 +847,12 @@ class KerykeionChartSVG:
733
847
 
734
848
  # Calculate element percentages
735
849
  total_elements = self.fire + self.water + self.earth + self.air
736
- fire_percentage = int(round(100 * self.fire / total_elements))
737
- earth_percentage = int(round(100 * self.earth / total_elements))
738
- air_percentage = int(round(100 * self.air / total_elements))
739
- 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"]
740
856
 
741
857
  # Element Percentages
742
858
  template_dict["elements_string"] = f"{self.language_settings.get('elements', 'Elements')}:"
@@ -748,9 +864,11 @@ class KerykeionChartSVG:
748
864
 
749
865
  # Qualities Percentages
750
866
  total_qualities = self.cardinal + self.fixed + self.mutable
751
- cardinal_percentage = int(round(100 * self.cardinal / total_qualities))
752
- fixed_percentage = int(round(100 * self.fixed / total_qualities))
753
- 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"]
754
872
 
755
873
  template_dict["qualities_string"] = f"{self.language_settings.get('qualities', 'Qualities')}:"
756
874
  template_dict["cardinal_string"] = f"{self.language_settings.get('cardinal', 'Cardinal')} {cardinal_percentage}%"
@@ -760,13 +878,16 @@ class KerykeionChartSVG:
760
878
  # Get houses list for main subject
761
879
  first_subject_houses_list = get_houses_list(self.first_obj)
762
880
 
881
+ # Chart title
882
+ template_dict["stringTitle"] = self._get_chart_title()
883
+
763
884
  # ------------------------------- #
764
885
  # CHART TYPE SPECIFIC SETTINGS #
765
886
  # ------------------------------- #
766
887
 
767
- if self.chart_type in ["Natal", "ExternalNatal"]:
768
- # Set viewbox
769
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
888
+ if self.chart_type == "Natal":
889
+ # Set viewbox dynamically
890
+ template_dict["viewbox"] = self._dynamic_viewbox()
770
891
 
771
892
  # Rings and circles
772
893
  template_dict["transitRing"] = ""
@@ -811,9 +932,6 @@ class KerykeionChartSVG:
811
932
  )
812
933
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
813
934
 
814
- # Chart title
815
- template_dict["stringTitle"] = f'{self.first_obj.name} - {self.language_settings.get("birth_chart", "Birth Chart")}'
816
-
817
935
  # Top left section
818
936
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
819
937
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -823,7 +941,8 @@ class KerykeionChartSVG:
823
941
  template_dict["top_left_2"] = f"{self.language_settings['latitude']}: {latitude_string}"
824
942
  template_dict["top_left_3"] = f"{self.language_settings['longitude']}: {longitude_string}"
825
943
  template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
826
- 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
827
946
 
828
947
  # Bottom left section
829
948
  if self.first_obj.zodiac_type == "Tropic":
@@ -871,6 +990,7 @@ class KerykeionChartSVG:
871
990
  c1=self.first_circle_radius,
872
991
  c3=self.third_circle_radius,
873
992
  chart_type=self.chart_type,
993
+ external_view=self.external_view,
874
994
  )
875
995
 
876
996
  template_dict["makePlanets"] = draw_planets(
@@ -881,6 +1001,7 @@ class KerykeionChartSVG:
881
1001
  third_circle_radius=self.third_circle_radius,
882
1002
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
883
1003
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1004
+ external_view=self.external_view,
884
1005
  )
885
1006
 
886
1007
  template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
@@ -895,8 +1016,8 @@ class KerykeionChartSVG:
895
1016
  template_dict["makeHouseComparisonGrid"] = ""
896
1017
 
897
1018
  elif self.chart_type == "Composite":
898
- # Set viewbox
899
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1019
+ # Set viewbox dynamically
1020
+ template_dict["viewbox"] = self._dynamic_viewbox()
900
1021
 
901
1022
  # Rings and circles
902
1023
  template_dict["transitRing"] = ""
@@ -941,9 +1062,6 @@ class KerykeionChartSVG:
941
1062
  )
942
1063
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
943
1064
 
944
- # Chart title
945
- template_dict["stringTitle"] = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
946
-
947
1065
  # Top left section
948
1066
  # First subject
949
1067
  latitude = convert_latitude_coordinate_to_string(
@@ -1015,6 +1133,7 @@ class KerykeionChartSVG:
1015
1133
  c1=self.first_circle_radius,
1016
1134
  c3=self.third_circle_radius,
1017
1135
  chart_type=self.chart_type,
1136
+ external_view=self.external_view,
1018
1137
  )
1019
1138
 
1020
1139
  template_dict["makePlanets"] = draw_planets(
@@ -1025,6 +1144,7 @@ class KerykeionChartSVG:
1025
1144
  third_circle_radius=self.third_circle_radius,
1026
1145
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1027
1146
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1147
+ external_view=self.external_view,
1028
1148
  )
1029
1149
 
1030
1150
  subject_name = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
@@ -1055,11 +1175,8 @@ class KerykeionChartSVG:
1055
1175
  template_dict["fixed_string"] = ""
1056
1176
  template_dict["mutable_string"] = ""
1057
1177
 
1058
- # Set viewbox
1059
- if self.double_chart_aspect_grid_type == "table":
1060
- template_dict["viewbox"] = self._TRANSIT_CHART_WITH_TABLE_VIWBOX
1061
- else:
1062
- template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1178
+ # Set viewbox dynamically
1179
+ template_dict["viewbox"] = self._dynamic_viewbox()
1063
1180
 
1064
1181
  # Get houses list for secondary subject
1065
1182
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1099,7 +1216,7 @@ class KerykeionChartSVG:
1099
1216
  if self.double_chart_aspect_grid_type == "list":
1100
1217
  title = f'{self.first_obj.name} - {self.language_settings.get("transit_aspects", "Transit Aspects")}'
1101
1218
  template_dict["makeAspectGrid"] = ""
1102
- 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]
1103
1220
  else:
1104
1221
  template_dict["makeAspectGrid"] = ""
1105
1222
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
@@ -1112,9 +1229,6 @@ class KerykeionChartSVG:
1112
1229
 
1113
1230
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1114
1231
 
1115
- # Chart title
1116
- template_dict["stringTitle"] = f"{self.language_settings['transits']} {format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime)}" # type: ignore
1117
-
1118
1232
  # Top left section
1119
1233
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1120
1234
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -1177,6 +1291,7 @@ class KerykeionChartSVG:
1177
1291
  c1=self.first_circle_radius,
1178
1292
  c3=self.third_circle_radius,
1179
1293
  chart_type=self.chart_type,
1294
+ external_view=self.external_view,
1180
1295
  second_subject_houses_list=second_subject_houses_list,
1181
1296
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1182
1297
  )
@@ -1190,6 +1305,7 @@ class KerykeionChartSVG:
1190
1305
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1191
1306
  chart_type=self.chart_type,
1192
1307
  third_circle_radius=self.third_circle_radius,
1308
+ external_view=self.external_view,
1193
1309
  )
1194
1310
 
1195
1311
  # Planet grids
@@ -1215,8 +1331,8 @@ class KerykeionChartSVG:
1215
1331
 
1216
1332
  # House comparison grid
1217
1333
  house_comparison_factory = HouseComparisonFactory(
1218
- first_subject=self.first_obj,
1219
- 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]
1220
1336
  active_points=self.active_points,
1221
1337
  )
1222
1338
  house_comparison = house_comparison_factory.get_house_comparison()
@@ -1229,12 +1345,12 @@ class KerykeionChartSVG:
1229
1345
  house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1230
1346
  return_point_label=self.language_settings.get("transit_point", "Transit Point"),
1231
1347
  natal_house_label=self.language_settings.get("house_position", "Natal House"),
1232
- x_position=930,
1348
+ x_position=980,
1233
1349
  )
1234
1350
 
1235
1351
  elif self.chart_type == "Synastry":
1236
- # Set viewbox
1237
- template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1352
+ # Set viewbox dynamically
1353
+ template_dict["viewbox"] = self._dynamic_viewbox()
1238
1354
 
1239
1355
  # Get houses list for secondary subject
1240
1356
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1274,10 +1390,10 @@ class KerykeionChartSVG:
1274
1390
  if self.double_chart_aspect_grid_type == "list":
1275
1391
  template_dict["makeAspectGrid"] = ""
1276
1392
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1277
- 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]
1278
1394
  self.aspects_list,
1279
- self.planets_settings,
1280
- self.aspects_settings
1395
+ self.planets_settings, # type: ignore[arg-type]
1396
+ self.aspects_settings # type: ignore[arg-type]
1281
1397
  )
1282
1398
  else:
1283
1399
  template_dict["makeAspectGrid"] = ""
@@ -1291,9 +1407,6 @@ class KerykeionChartSVG:
1291
1407
 
1292
1408
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1293
1409
 
1294
- # Chart title
1295
- template_dict["stringTitle"] = f"{self.first_obj.name} {self.language_settings['and_word']} {self.second_obj.name}" # type: ignore
1296
-
1297
1410
  # Top left section
1298
1411
  template_dict["top_left_0"] = f"{self.first_obj.name}:"
1299
1412
  template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore
@@ -1344,6 +1457,7 @@ class KerykeionChartSVG:
1344
1457
  c1=self.first_circle_radius,
1345
1458
  c3=self.third_circle_radius,
1346
1459
  chart_type=self.chart_type,
1460
+ external_view=self.external_view,
1347
1461
  second_subject_houses_list=second_subject_houses_list,
1348
1462
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1349
1463
  )
@@ -1357,6 +1471,7 @@ class KerykeionChartSVG:
1357
1471
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1358
1472
  chart_type=self.chart_type,
1359
1473
  third_circle_radius=self.third_circle_radius,
1474
+ external_view=self.external_view,
1360
1475
  )
1361
1476
 
1362
1477
  # Planet grid
@@ -1378,9 +1493,9 @@ class KerykeionChartSVG:
1378
1493
  )
1379
1494
  template_dict["makeHouseComparisonGrid"] = ""
1380
1495
 
1381
- elif self.chart_type == "Return":
1382
- # Set viewbox
1383
- 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()
1384
1499
 
1385
1500
  # Get houses list for secondary subject
1386
1501
  second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
@@ -1420,7 +1535,7 @@ class KerykeionChartSVG:
1420
1535
  if self.double_chart_aspect_grid_type == "list":
1421
1536
  title = self.language_settings.get("return_aspects", "Natal to Return Aspects")
1422
1537
  template_dict["makeAspectGrid"] = ""
1423
- 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]
1424
1539
  else:
1425
1540
  template_dict["makeAspectGrid"] = ""
1426
1541
  template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
@@ -1433,12 +1548,6 @@ class KerykeionChartSVG:
1433
1548
 
1434
1549
  template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1435
1550
 
1436
- # Chart title
1437
- if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1438
- template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('solar_return', 'Solar Return')}"
1439
- else:
1440
- template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('lunar_return', 'Lunar Return')}"
1441
-
1442
1551
 
1443
1552
  # Top left section
1444
1553
  # Subject
@@ -1510,6 +1619,7 @@ class KerykeionChartSVG:
1510
1619
  c1=self.first_circle_radius,
1511
1620
  c3=self.third_circle_radius,
1512
1621
  chart_type=self.chart_type,
1622
+ external_view=self.external_view,
1513
1623
  second_subject_houses_list=second_subject_houses_list,
1514
1624
  transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1515
1625
  )
@@ -1523,6 +1633,7 @@ class KerykeionChartSVG:
1523
1633
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1524
1634
  chart_type=self.chart_type,
1525
1635
  third_circle_radius=self.third_circle_radius,
1636
+ external_view=self.external_view,
1526
1637
  )
1527
1638
 
1528
1639
  # Planet grid
@@ -1550,8 +1661,8 @@ class KerykeionChartSVG:
1550
1661
  )
1551
1662
 
1552
1663
  house_comparison_factory = HouseComparisonFactory(
1553
- first_subject=self.first_obj,
1554
- 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]
1555
1666
  active_points=self.active_points,
1556
1667
  )
1557
1668
  house_comparison = house_comparison_factory.get_house_comparison()
@@ -1563,13 +1674,13 @@ class KerykeionChartSVG:
1563
1674
  points_owner_subject_number=2, # The second subject is the Solar Return
1564
1675
  house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1565
1676
  return_point_label=self.language_settings.get("return_point", "Return Point"),
1566
- return_label=self.language_settings.get("Return", "Return"),
1677
+ return_label=self.language_settings.get("Return", "DualReturnChart"),
1567
1678
  radix_label=self.language_settings.get("Natal", "Natal"),
1568
1679
  )
1569
1680
 
1570
- elif self.chart_type == "SingleWheelReturn":
1571
- # Set viewbox
1572
- template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1681
+ elif self.chart_type == "SingleReturnChart":
1682
+ # Set viewbox dynamically
1683
+ template_dict["viewbox"] = self._dynamic_viewbox()
1573
1684
 
1574
1685
  # Rings and circles
1575
1686
  template_dict["transitRing"] = ""
@@ -1614,9 +1725,6 @@ class KerykeionChartSVG:
1614
1725
  )
1615
1726
  template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1616
1727
 
1617
- # Chart title
1618
- template_dict["stringTitle"] = self.first_obj.name
1619
-
1620
1728
  # Top left section
1621
1729
  latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1622
1730
  longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
@@ -1678,6 +1786,7 @@ class KerykeionChartSVG:
1678
1786
  c1=self.first_circle_radius,
1679
1787
  c3=self.third_circle_radius,
1680
1788
  chart_type=self.chart_type,
1789
+ external_view=self.external_view,
1681
1790
  )
1682
1791
 
1683
1792
  template_dict["makePlanets"] = draw_planets(
@@ -1688,6 +1797,7 @@ class KerykeionChartSVG:
1688
1797
  third_circle_radius=self.third_circle_radius,
1689
1798
  main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1690
1799
  main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1800
+ external_view=self.external_view,
1691
1801
  )
1692
1802
 
1693
1803
  template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
@@ -1701,9 +1811,9 @@ class KerykeionChartSVG:
1701
1811
  template_dict["makeSecondaryPlanetGrid"] = ""
1702
1812
  template_dict["makeHouseComparisonGrid"] = ""
1703
1813
 
1704
- return ChartTemplateDictionary(**template_dict)
1814
+ return ChartTemplateModel(**template_dict)
1705
1815
 
1706
- 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:
1707
1817
  """
1708
1818
  Render the full chart SVG as a string.
1709
1819
 
@@ -1724,11 +1834,11 @@ class KerykeionChartSVG:
1724
1834
 
1725
1835
  # read template
1726
1836
  with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
1727
- template = Template(f.read()).substitute(td)
1837
+ template = Template(f.read()).substitute(td.model_dump())
1728
1838
 
1729
1839
  # return filename
1730
1840
 
1731
- logging.debug(f"Template dictionary keys: {td.keys()}")
1841
+ logging.debug(f"Template dictionary has {len(td.model_dump())} fields")
1732
1842
 
1733
1843
  self._create_template_dictionary()
1734
1844
 
@@ -1743,36 +1853,50 @@ class KerykeionChartSVG:
1743
1853
 
1744
1854
  return template
1745
1855
 
1746
- 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):
1747
1857
  """
1748
1858
  Generate and save the full chart SVG to disk.
1749
1859
 
1750
- Calls makeTemplate to render the SVG, then writes a file named
1751
- "{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.
1752
1862
 
1753
1863
  Args:
1754
- minify (bool): Pass-through to makeTemplate for compact output.
1755
- 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.
1756
1870
 
1757
1871
  Returns:
1758
1872
  None
1759
1873
  """
1760
1874
 
1761
- self.template = self.makeTemplate(minify, remove_css_variables)
1875
+ self.template = self.generate_svg_string(minify, remove_css_variables)
1762
1876
 
1763
- 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":
1764
- chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Lunar Return.svg"
1765
- 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":
1766
- 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"
1767
1883
  else:
1768
- 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"
1769
1893
 
1770
1894
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1771
1895
  output_file.write(self.template)
1772
1896
 
1773
1897
  print(f"SVG Generated Correctly in: {chartname}")
1774
1898
 
1775
- 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):
1776
1900
  """
1777
1901
  Render the wheel-only chart SVG as a string.
1778
1902
 
@@ -1796,7 +1920,9 @@ class KerykeionChartSVG:
1796
1920
  template = f.read()
1797
1921
 
1798
1922
  template_dict = self._create_template_dictionary()
1799
- 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})
1800
1926
 
1801
1927
  if remove_css_variables:
1802
1928
  template = inline_css_variables_in_svg(template)
@@ -1809,30 +1935,44 @@ class KerykeionChartSVG:
1809
1935
 
1810
1936
  return template
1811
1937
 
1812
- 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):
1813
1939
  """
1814
1940
  Generate and save wheel-only chart SVG to disk.
1815
1941
 
1816
- Calls makeWheelOnlyTemplate and writes a file named
1817
- "{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.
1818
1944
 
1819
1945
  Args:
1820
- minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
1821
- 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.
1822
1952
 
1823
1953
  Returns:
1824
1954
  None
1825
1955
  """
1826
1956
 
1827
- template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
1828
- 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"
1829
1969
 
1830
1970
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1831
1971
  output_file.write(template)
1832
1972
 
1833
1973
  print(f"SVG Generated Correctly in: {chartname}")
1834
1974
 
1835
- 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):
1836
1976
  """
1837
1977
  Render the aspect-grid-only chart SVG as a string.
1838
1978
 
@@ -1857,7 +1997,7 @@ class KerykeionChartSVG:
1857
1997
 
1858
1998
  template_dict = self._create_template_dictionary()
1859
1999
 
1860
- if self.chart_type in ["Transit", "Synastry", "Return"]:
2000
+ if self.chart_type in ["Transit", "Synastry", "DualReturnChart"]:
1861
2001
  aspects_grid = draw_transit_aspect_grid(
1862
2002
  self.chart_colors_settings["paper_0"],
1863
2003
  self.available_planets_setting,
@@ -1872,7 +2012,10 @@ class KerykeionChartSVG:
1872
2012
  y_start=250,
1873
2013
  )
1874
2014
 
1875
- 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})
1876
2019
 
1877
2020
  if remove_css_variables:
1878
2021
  template = inline_css_variables_in_svg(template)
@@ -1885,42 +2028,55 @@ class KerykeionChartSVG:
1885
2028
 
1886
2029
  return template
1887
2030
 
1888
- 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):
1889
2032
  """
1890
2033
  Generate and save aspect-grid-only chart SVG to disk.
1891
2034
 
1892
- Calls makeAspectGridOnlyTemplate and writes a file named
1893
- "{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.
1894
2037
 
1895
2038
  Args:
1896
- minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
1897
- 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.
1898
2045
 
1899
2046
  Returns:
1900
2047
  None
1901
2048
  """
1902
2049
 
1903
- template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
1904
- 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"
1905
2062
 
1906
2063
  with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1907
2064
  output_file.write(template)
1908
2065
 
1909
2066
  print(f"SVG Generated Correctly in: {chartname}")
1910
2067
 
1911
-
1912
2068
  if __name__ == "__main__":
1913
2069
  from kerykeion.utilities import setup_logging
1914
2070
  from kerykeion.planetary_return_factory import PlanetaryReturnFactory
1915
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
1916
2074
 
1917
- ACTIVE_PLANETS: list[AstrologicalPoint] = [
1918
- "Sun", "Moon", "Pars_Fortunae", "Mercury", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", "Chiron", "True_Node"
1919
- ]
1920
-
2075
+ ACTIVE_PLANETS: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS
2076
+ # ACTIVE_PLANETS: list[AstrologicalPoint] = ALL_ACTIVE_POINTS
1921
2077
  setup_logging(level="info")
1922
2078
 
1923
- 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)
1924
2080
 
1925
2081
  return_factory = PlanetaryReturnFactory(
1926
2082
  subject,
@@ -1933,101 +2089,118 @@ if __name__ == "__main__":
1933
2089
  )
1934
2090
 
1935
2091
  ###
1936
- ## Birth Chart
1937
- birth_chart = KerykeionChartSVG(
1938
- 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,
1939
2099
  chart_language="IT",
1940
2100
  theme="strawberry",
1941
- active_points=ACTIVE_PLANETS,
1942
2101
  )
1943
- birth_chart.makeSVG() # minify=True, remove_css_variables=True)
2102
+ birth_chart.save_svg() # minify=True, remove_css_variables=True)
1944
2103
 
1945
2104
  ###
1946
- ## Solar Return Chart
2105
+ ## Solar Return Chart - NEW APPROACH with ChartDataFactory
1947
2106
  solar_return = return_factory.next_return_from_iso_formatted_time(
1948
2107
  "2025-01-09T18:30:00+01:00", # UTC+1
1949
2108
  return_type="Solar",
1950
2109
  )
1951
- solar_return_chart = KerykeionChartSVG(
1952
- first_obj=subject, chart_type="Return",
1953
- 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,
1954
2117
  chart_language="IT",
1955
2118
  theme="classic",
1956
- active_points=ACTIVE_PLANETS,
1957
2119
  )
1958
2120
 
1959
- solar_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2121
+ solar_return_chart.save_svg() # minify=True, remove_css_variables=True)
1960
2122
 
1961
2123
  ###
1962
- ## Single wheel return
1963
- single_wheel_return_chart = KerykeionChartSVG(
1964
- first_obj=solar_return,
1965
- chart_type="SingleWheelReturn",
1966
- 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,
1967
2131
  chart_language="IT",
1968
2132
  theme="dark",
1969
- active_points=ACTIVE_PLANETS,
1970
2133
  )
1971
2134
 
1972
- single_wheel_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2135
+ single_wheel_return_chart.save_svg() # minify=True, remove_css_variables=True)
1973
2136
 
1974
2137
  ###
1975
- ## Lunar return
2138
+ ## Lunar return - NEW APPROACH with ChartDataFactory
1976
2139
  lunar_return = return_factory.next_return_from_iso_formatted_time(
1977
2140
  "2025-01-09T18:30:00+01:00", # UTC+1
1978
2141
  return_type="Lunar",
1979
2142
  )
1980
- lunar_return_chart = KerykeionChartSVG(
1981
- first_obj=subject,
1982
- chart_type="Return",
1983
- 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,
1984
2150
  chart_language="IT",
1985
2151
  theme="dark",
1986
- active_points=ACTIVE_PLANETS,
1987
2152
  )
1988
- lunar_return_chart.makeSVG() # minify=True, remove_css_variables=True)
2153
+ lunar_return_chart.save_svg() # minify=True, remove_css_variables=True)
1989
2154
 
1990
2155
  ###
1991
- ## Transit Chart
2156
+ ## Transit Chart - NEW APPROACH with ChartDataFactory
1992
2157
  transit = AstrologicalSubjectFactory.from_iso_utc_time(
1993
2158
  "Transit",
1994
2159
  "2021-10-04T18:30:00+01:00",
1995
2160
  )
1996
- transit_chart = KerykeionChartSVG(
1997
- first_obj=subject,
1998
- chart_type="Transit",
1999
- 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,
2000
2168
  chart_language="IT",
2001
2169
  theme="dark",
2002
- active_points=ACTIVE_PLANETS
2003
2170
  )
2004
- transit_chart.makeSVG() # minify=True, remove_css_variables=True)
2171
+ transit_chart.save_svg() # minify=True, remove_css_variables=True)
2005
2172
 
2006
2173
  ###
2007
- ## Synastry Chart
2174
+ ## Synastry Chart - NEW APPROACH with ChartDataFactory
2008
2175
  second_subject = AstrologicalSubjectFactory.from_birth_data("Yoko Ono", 1933, 2, 18, 18, 30, "Tokyo", "JP")
2009
- synastry_chart = KerykeionChartSVG(
2010
- first_obj=subject,
2011
- chart_type="Synastry",
2012
- 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,
2013
2183
  chart_language="IT",
2014
2184
  theme="dark",
2015
- active_points=ACTIVE_PLANETS
2016
2185
  )
2017
- synastry_chart.makeSVG() # minify=True, remove_css_variables=True)
2186
+ synastry_chart.save_svg() # minify=True, remove_css_variables=True)
2018
2187
 
2019
2188
  ##
2020
- # Transit Chart with Grid
2189
+ # Transit Chart with Grid - NEW APPROACH with ChartDataFactory
2021
2190
  subject.name = "Grid"
2022
- transit_chart_with_grid = KerykeionChartSVG(
2023
- first_obj=subject,
2024
- chart_type="Transit",
2025
- 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,
2026
2198
  chart_language="IT",
2027
2199
  theme="dark",
2028
- active_points=ACTIVE_PLANETS,
2029
2200
  double_chart_aspect_grid_type="table"
2030
2201
  )
2031
- transit_chart_with_grid.makeSVG() # minify=True, remove_css_variables=True)
2032
- transit_chart_with_grid.makeAspectGridOnlySVG()
2033
- 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!")