Skip to content

Chart

natal.chart

This module provides the Chart class for generating SVG representations of natal charts. It includes functionality for creating sign wheels, house wheels, body placements, and aspect lines for both single and composite charts.

Chart

Bases: DotDict

SVG representation of a natal chart.

This class generates the visual components of an astrological chart, including sign wheels, house wheels, planet placements, and aspect lines. It supports both single and composite charts.

Source code in natal/chart.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
class Chart(DotDict):
    """SVG representation of a natal chart.

    This class generates the visual components of an astrological chart,
    including sign wheels, house wheels, planet placements, and aspect lines.
    It supports both single and composite charts.
    """

    def __init__(
        self,
        data1: Data,
        width: int,
        height: int | None = None,
        data2: Data | None = None,
    ) -> None:
        """Initialize a Chart object.

        Args:
            data1: Primary chart data
            width: Width of the SVG
            height: Height of the SVG. If None, set to width
            data2: Secondary chart data for composite charts

        Returns:
            None
        """
        self.data1 = data1
        self.data2 = data2
        self.width = width
        self.height = height
        if self.height is None:
            self.height = self.width
        self.cx = self.width / 2
        self.cy = self.height / 2

        self.config = self.data1.config
        margin = min(self.width, self.height) * self.config.chart.margin_factor
        self.max_radius = min(self.width - margin, self.height - margin) // 2
        self.margin = margin
        self.ring_thickness = (
            self.max_radius * self.config.chart.ring_thickness_fraction
        )
        self.font_size = self.ring_thickness * self.config.chart.font_size_fraction
        self.scale_adjustment = self.width / self.config.chart.scale_adj_factor
        self.pos_adjustment = self.font_size / self.config.chart.pos_adj_factor

    def svg_root(self, content: str | list[str]) -> str:
        """Generate an SVG root element with sensible defaults.

        Args:
            content: The content to be included in the SVG root

        Returns:
            An SVG root element as a string
        """
        return svg(
            content,
            height=self.height,
            width=self.width,
            font_family=self.config.chart.font,
            version="1.1",
            xmlns="http://www.w3.org/2000/svg",
        )

    def sector(
        self,
        radius: int,
        start_deg: float,
        end_deg: float,
        fill: str = "white",
        stroke_color: str = "black",
        stroke_width: float = 1,
        stroke_opacity: float = 1,
    ) -> str:
        """Create a sector shape in SVG format.

        Args:
            radius: Radius of the sector
            start_deg: Starting angle in degrees
            end_deg: Ending angle in degrees
            fill: Fill color of the sector
            stroke_color: Stroke color of the sector
            stroke_width: Width of the stroke
            stroke_opacity: Opacity of the stroke

        Returns:
            An SVG path element representing the sector
        """
        start_rad = radians(start_deg)
        end_rad = radians(end_deg)
        start_x = self.cx - radius * cos(start_rad)
        start_y = self.cy + radius * sin(start_rad)
        end_x = self.cx - radius * cos(end_rad)
        end_y = self.cy + radius * sin(end_rad)

        start_x, start_y, end_x, end_y = [
            round(val, 2) for val in (start_x, start_y, end_x, end_y)
        ]

        path_data = " ".join(
            (
                "M{} {}".format(self.cx, self.cy),
                "L{} {}".format(start_x, start_y),
                "A{} {} 0 0 0 {} {}".format(radius, radius, end_x, end_y),
                "Z",
            )
        )
        return path(
            "",
            d=path_data,
            fill=fill,
            stroke=stroke_color,
            stroke_width=stroke_width,
            stroke_opacity=stroke_opacity,
        )

    def background(self, radius: float, **kwargs) -> str:
        """Create a background circle for the chart.

        Args:
            radius: Radius of the background circle
            **kwargs: Additional attributes for the circle element

        Returns:
            An SVG circle element representing the background
        """
        return circle(cx=self.cx, cy=self.cy, r=radius, **kwargs)

    def sign_wheel(self) -> list[str]:
        """Generate the zodiac sign wheel.

        Returns:
            A list of SVG elements representing the sign wheel
        """
        radius = self.max_radius

        wheel = [self.background(radius=radius, fill=self.config.theme.background)]
        for i in range(12):
            start_deg = self.data1.signs[i].normalized_degree
            end_deg = start_deg + 30
            wheel.append(
                self.sector(
                    radius=radius,
                    start_deg=start_deg,
                    end_deg=end_deg,
                    fill=self.bg_colors[i],
                    stroke_color=self.config.theme.foreground,
                    stroke_width=self.config.chart.stroke_width,
                )
            )
        return wheel

    def sign_wheel_symbols(self) -> list[str]:
        """Generate the zodiac sign symbols for the sign wheel.

        Returns:
            A list of SVG elements representing the zodiac sign symbols
        """

        wheel = []
        for i in range(12):
            start_deg = self.data1.signs[i].normalized_degree
            symbol_radius = self.max_radius - (self.ring_thickness / 2)
            symbol_angle = radians(start_deg + 15)  # Center of the sector
            symbol_x = self.cx - symbol_radius * cos(symbol_angle) - self.pos_adjustment
            symbol_y = self.cy + symbol_radius * sin(symbol_angle) - self.pos_adjustment
            wheel.append(
                g(
                    [
                        circle(
                            cx=10,
                            cy=10,
                            r=12,
                            stroke="none",
                            # fill="red",
                            fill=self.bg_colors[i],
                        ),
                        svg_paths[SIGN_MEMBERS[i].name],
                    ],
                    stroke=self.config.theme[SIGN_MEMBERS[i].color],
                    stroke_width=self.config.chart.stroke_width * 1.5,
                    fill="none",
                    transform=f"translate({symbol_x}, {symbol_y}) scale({self.scale_adjustment})",
                )
            )
        return wheel

    def house_wheel(self) -> list[str]:
        """Generate the house wheel.

        Returns:
            A list of SVG elements representing the house wheel
        """
        radius = self.max_radius - self.ring_thickness
        wheel = [self.background(radius, fill=self.config.theme.background)]

        for i, (start_deg, end_deg) in enumerate(self.house_vertices):
            wheel.append(
                self.sector(
                    radius=radius,
                    start_deg=start_deg,
                    end_deg=end_deg,
                    fill=self.bg_colors[i],
                    stroke_color=self.config.theme.foreground,
                    stroke_width=self.config.chart.stroke_width,
                )
            )

            # Add house number
            number_width = self.font_size * 0.8
            number_radius = radius - (self.ring_thickness / 2)
            number_angle = radians(
                start_deg + ((end_deg - start_deg) % 360) / 2
            )  # Center of the house
            number_x = self.cx - number_radius * cos(number_angle)
            number_y = self.cy + number_radius * sin(number_angle)
            wheel.append(
                text(
                    str(i + 1),  # House numbers start from 1
                    x=number_x,
                    y=number_y,
                    fill=getattr(self.config.theme, SIGN_MEMBERS[i].color),
                    font_size=number_width,
                    text_anchor="middle",
                    dominant_baseline="central",
                )
            )

        return wheel

    def vertex_wheel(self) -> list[str]:
        """Generate vertex lines for the chart.

        Returns:
            A list of SVG elements representing vertex lines
        """
        vertex_radius = self.max_radius + self.margin // 2
        house_radius = self.max_radius - 2 * self.ring_thickness
        body_radius = self.max_radius - 3 * self.ring_thickness

        lines = [
            self.background(
                house_radius,
                fill=self.config.theme.background,
                stroke=self.config.theme.foreground,
                stroke_width=self.config.chart.stroke_width,
            ),
            self.background(
                body_radius,
                fill="#88888800",  # transparent
                stroke=self.config.theme.dim,
                stroke_width=self.config.chart.stroke_width,
            ),
        ]
        for house in self.data1.houses:
            radius = house_radius
            stroke_width = self.config.chart.stroke_width
            stroke_color = self.config.theme.dim

            if house.value in [1, 4, 7, 10]:
                radius = vertex_radius
                stroke_color = self.config.theme.foreground

            angle = radians(house.normalized_degree)
            end_x = self.cx - radius * cos(angle)
            end_y = self.cy + radius * sin(angle)

            lines.append(
                line(
                    x1=self.cx,
                    y1=self.cy,
                    x2=end_x,
                    y2=end_y,
                    stroke=stroke_color,
                    stroke_width=stroke_width,
                    stroke_opacity=self.config.chart.stroke_opacity,
                )
            )

        return lines

    def outer_body_wheel(self) -> list[str]:
        """Generate the outer body wheel for single or composite charts.

        Returns:
            A list of SVG elements representing the outer body wheel
        """
        radius = self.max_radius - 3 * self.ring_thickness
        data = self.data2 or self.data1
        return self.body_wheel(radius, data, self.config.chart.outer_min_degree)

    def inner_body_wheel(self) -> list[str] | None:
        """Generate the inner body wheel for composite charts.

        Returns:
            A list of SVG elements representing the inner body wheel, or None for single charts
        """
        if self.data2 is None:
            return
        radius = self.max_radius - 4 * self.ring_thickness
        data = self.data1
        return self.body_wheel(radius, data, self.config.chart.inner_min_degree)

    def outer_aspect(self) -> list[str]:
        """Generate aspect lines for the outer wheel in single charts.

        Returns:
            A list of SVG elements representing aspect lines
        """
        if self.data2 is not None:
            return []
        radius = self.max_radius - 3 * self.ring_thickness
        aspects = self.data1.aspects
        return self.aspect_lines(radius, aspects)

    def inner_aspect(self) -> list[str]:
        """Generate aspect lines for the inner wheel in composite charts.

        Returns:
            A list of SVG elements representing aspect lines
        """
        if self.data2 is None:
            return []
        radius = self.max_radius - 4 * self.ring_thickness
        aspects = self.data1.calculate_aspects(
            self.data1.composite_aspects_pairs(self.data2)
        )
        return self.aspect_lines(radius, aspects)

    @property
    def svg(self) -> str:
        """Generate the SVG representation of the chart.

        Returns:
            str: SVG content.
        """
        return self.svg_root(
            [
                self.sign_wheel(),
                self.house_wheel(),
                self.vertex_wheel(),
                self.sign_wheel_symbols(),
                self.outer_body_wheel(),
                self.inner_body_wheel(),
                self.outer_aspect(),
                self.inner_aspect(),
            ]
        )

    # utils ======================================================

    def adjusted_degrees(self, degrees: list[float], min_degree: float) -> list[float]:
        """Adjust spacing between celestial bodies to avoid overlap.

        Args:
            degrees: Sorted normalized degrees of celestial bodies
            min_degree: Minimum allowed degree separation

        Returns:
            Adjusted degrees of celestial bodies
        """
        step = min_degree + 0.1  # prevent overlap for float precision
        n = len(degrees)

        fwd_degs = degrees.copy()
        bwd_degs = degrees[::-1]

        # Forward adjustment
        changed = True
        while changed:
            changed = False
            for i in range(n):
                prev_deg = fwd_degs[-1] - 360 if i == 0 else fwd_degs[i - 1]
                delta = fwd_degs[i] - prev_deg
                diff = min(delta, 360 - delta)
                if (fwd_degs[i] < prev_deg) or (diff < min_degree):
                    fwd_degs[i] = prev_deg + step
                    changed = True

        # Backward adjustment
        changed = True
        while changed:
            changed = False
            for i in range(n):
                prev_deg = bwd_degs[-1] + 360 if i == 0 else bwd_degs[i - 1]
                delta = prev_deg - bwd_degs[i]
                diff = min(delta, 360 - delta)
                if (prev_deg < bwd_degs[i]) or (diff < min_degree):
                    bwd_degs[i] = prev_deg - step
                    changed = True

        bwd_degs.reverse()

        # average forward and backward adjustments
        avg_adj = []
        for fwd, bwd in zip(fwd_degs, bwd_degs):
            fwd %= 360
            bwd %= 360
            if abs(fwd - bwd) < 180:
                avg = (fwd + bwd) / 2
            else:
                avg = ((fwd + bwd + 360) / 2) % 360
            avg_adj.append(avg)

        return avg_adj

    def body_wheel(
        self, wheel_radius: float, data: Data, min_degree: float
    ) -> list[str]:
        """Generate elements for both inner and outer body wheels.

        Args:
            wheel_radius: Radius of the wheel
            data: Chart data to use
            min_degree: Minimum degree separation between bodies

        Returns:
            A list of SVG elements representing the body wheel
        """

        def norm_deg(x):
            return self.data1.normalize(x.degree)

        sorted_norm_bodies = sorted(data.aspectables, key=norm_deg)
        sorted_norm_degs = [norm_deg(b) for b in sorted_norm_bodies]

        # Calculate adjusted positions
        adj_norm_degs = (
            self.adjusted_degrees(sorted_norm_degs, min_degree)
            if len(sorted_norm_bodies) > 1
            else sorted_norm_degs
        )
        # for tests only
        self.adj_degs_len = len(adj_norm_degs)

        output = []
        for body, adj_deg in zip(sorted_norm_bodies, adj_norm_degs):
            g_opt = {
                "fill": "none",
                "stroke": self.config.theme[body.color],
                "stroke_width": self.config.chart.stroke_width * 1.5,
            }

            # special handling for asc, ic, dsc and mc
            if body.name in VERTEX_NAMES:
                g_opt["fill"] = self.config.theme[body.color]
                g_opt["stroke"] = "none"

            symbol_radius = wheel_radius + (self.ring_thickness / 2)

            # Use original angle for line start position
            original_angle = radians(self.data1.normalize(body.degree))
            degree_x = self.cx - wheel_radius * cos(original_angle)
            degree_y = self.cy + wheel_radius * sin(original_angle)

            # Use adjusted angle for symbol position
            adjusted_angle = radians(adj_deg)
            symbol_x = self.cx - symbol_radius * cos(adjusted_angle)
            symbol_y = self.cy + symbol_radius * sin(adjusted_angle)

            # Add line connecting to the inner circle
            inner_radius = wheel_radius - self.ring_thickness
            inner_x = self.cx - inner_radius * cos(original_angle)
            inner_y = self.cy + inner_radius * sin(original_angle)

            output.extend(
                [
                    line(
                        x1=degree_x,
                        y1=degree_y,
                        x2=symbol_x,
                        y2=symbol_y,
                        stroke=self.config.theme[body.color],
                        stroke_width=self.config.chart.stroke_width / 2,
                    ),
                    circle(
                        cx=symbol_x,
                        cy=symbol_y,
                        r=self.font_size / 2,
                        # fill="red",  # for testing only
                        fill=self.config.theme.background,
                    ),
                    line(
                        x1=degree_x,
                        y1=degree_y,
                        x2=inner_x,
                        y2=inner_y,
                        stroke=self.config.theme.dim,
                        stroke_width=self.config.chart.stroke_width / 2,
                        stroke_dasharray=self.ring_thickness / 11,
                    ),
                    g(
                        svg_paths[body.name],
                        transform=f"translate({symbol_x - self.pos_adjustment}, {symbol_y - self.pos_adjustment}) scale({self.scale_adjustment})",
                        **g_opt,
                    ),
                ]
            )
        return output

    def aspect_lines(self, radius: float, aspects: list[Aspect]) -> list[str]:
        """Draw aspect lines between aspectable celestial bodies.

        Args:
            radius: Radius of the aspect wheel
            aspects: List of aspects to draw

        Returns:
            A list of SVG elements representing aspect lines
        """
        bg = [
            self.background(
                radius,
                fill=self.config.theme.background,
                stroke=self.config.theme.dim,
                stroke_width=self.config.chart.stroke_width,
            )
        ]
        aspect_lines = []
        for aspect in aspects:
            start_angle = radians(self.data1.normalize(aspect.body1.degree))
            end_angle = radians(self.data1.normalize(aspect.body2.degree))
            orb_config = self.config.orb[aspect.aspect_member.name]
            if not orb_config:
                continue
            orb_fraction = 1 - aspect.orb / orb_config
            opacity_factor = (
                1 if aspect.aspect_member.name == "conjunction" else orb_fraction
            )
            aspect_lines.append(
                line(
                    x1=self.cx - radius * cos(start_angle),
                    y1=self.cy + radius * sin(start_angle),
                    x2=self.cx - radius * cos(end_angle),
                    y2=self.cy + radius * sin(end_angle),
                    stroke=self.config.theme[aspect.aspect_member.color],
                    stroke_width=self.config.chart.stroke_width / 2,
                    stroke_opacity=self.config.chart.stroke_opacity * opacity_factor,
                )
            )

        self.aspect_lines_len = len(aspect_lines)  # for test only
        return bg + aspect_lines

    @cached_property
    def house_vertices(self) -> list[tuple[float, float]]:
        """Calculate the vertices (start and end degrees) of each house.

        Returns:
            A list of tuples containing start and end degrees for each house
        """
        vertices = []
        for i in range(12):
            next_i = (i + 1) % 12
            start_deg = self.data1.houses[i].normalized_degree
            end_deg = self.data1.houses[next_i].normalized_degree
            # Handle the case where end_deg is less than start_deg (crosses 0°)
            if end_deg < start_deg:
                end_deg += 360
            vertices.append((start_deg, end_deg))

        return vertices

    @cached_property
    def bg_colors(self) -> list[str]:
        """Get the background colors for each house.

        Returns:
            A list of hex color strings for house backgrounds
        """

        def hex_to_rgb(hex_value):
            hex_value = hex_value.lstrip("#")
            return tuple(int(hex_value[i : i + 2], 16) for i in (0, 2, 4))

        def rgb_to_hex(rgb):
            return "#" + "".join(f"{i:02x}" for i in rgb)

        trans = self.config.theme.transparency
        output = []
        for i in range(4):
            hex_color = getattr(self.config.theme, SIGN_MEMBERS[i].color)
            rgb_color = hex_to_rgb(hex_color)
            rgb_bg = hex_to_rgb(self.config.theme.background)
            # blend the color with the background
            blended_rgb = tuple(
                int(trans * rgb_color[i] + (1 - trans) * rgb_bg[i]) for i in range(3)
            )
            output.append(rgb_to_hex(blended_rgb))

        return output * 4

bg_colors: list[str] cached property

Get the background colors for each house.

Returns:

Type Description
list[str]

A list of hex color strings for house backgrounds

house_vertices: list[tuple[float, float]] cached property

Calculate the vertices (start and end degrees) of each house.

Returns:

Type Description
list[tuple[float, float]]

A list of tuples containing start and end degrees for each house

svg: str property

Generate the SVG representation of the chart.

Returns:

Name Type Description
str str

SVG content.

__init__(data1: Data, width: int, height: int | None = None, data2: Data | None = None) -> None

Initialize a Chart object.

Parameters:

Name Type Description Default
data1 Data

Primary chart data

required
width int

Width of the SVG

required
height int | None

Height of the SVG. If None, set to width

None
data2 Data | None

Secondary chart data for composite charts

None

Returns:

Type Description
None

None

Source code in natal/chart.py
def __init__(
    self,
    data1: Data,
    width: int,
    height: int | None = None,
    data2: Data | None = None,
) -> None:
    """Initialize a Chart object.

    Args:
        data1: Primary chart data
        width: Width of the SVG
        height: Height of the SVG. If None, set to width
        data2: Secondary chart data for composite charts

    Returns:
        None
    """
    self.data1 = data1
    self.data2 = data2
    self.width = width
    self.height = height
    if self.height is None:
        self.height = self.width
    self.cx = self.width / 2
    self.cy = self.height / 2

    self.config = self.data1.config
    margin = min(self.width, self.height) * self.config.chart.margin_factor
    self.max_radius = min(self.width - margin, self.height - margin) // 2
    self.margin = margin
    self.ring_thickness = (
        self.max_radius * self.config.chart.ring_thickness_fraction
    )
    self.font_size = self.ring_thickness * self.config.chart.font_size_fraction
    self.scale_adjustment = self.width / self.config.chart.scale_adj_factor
    self.pos_adjustment = self.font_size / self.config.chart.pos_adj_factor

adjusted_degrees(degrees: list[float], min_degree: float) -> list[float]

Adjust spacing between celestial bodies to avoid overlap.

Parameters:

Name Type Description Default
degrees list[float]

Sorted normalized degrees of celestial bodies

required
min_degree float

Minimum allowed degree separation

required

Returns:

Type Description
list[float]

Adjusted degrees of celestial bodies

Source code in natal/chart.py
def adjusted_degrees(self, degrees: list[float], min_degree: float) -> list[float]:
    """Adjust spacing between celestial bodies to avoid overlap.

    Args:
        degrees: Sorted normalized degrees of celestial bodies
        min_degree: Minimum allowed degree separation

    Returns:
        Adjusted degrees of celestial bodies
    """
    step = min_degree + 0.1  # prevent overlap for float precision
    n = len(degrees)

    fwd_degs = degrees.copy()
    bwd_degs = degrees[::-1]

    # Forward adjustment
    changed = True
    while changed:
        changed = False
        for i in range(n):
            prev_deg = fwd_degs[-1] - 360 if i == 0 else fwd_degs[i - 1]
            delta = fwd_degs[i] - prev_deg
            diff = min(delta, 360 - delta)
            if (fwd_degs[i] < prev_deg) or (diff < min_degree):
                fwd_degs[i] = prev_deg + step
                changed = True

    # Backward adjustment
    changed = True
    while changed:
        changed = False
        for i in range(n):
            prev_deg = bwd_degs[-1] + 360 if i == 0 else bwd_degs[i - 1]
            delta = prev_deg - bwd_degs[i]
            diff = min(delta, 360 - delta)
            if (prev_deg < bwd_degs[i]) or (diff < min_degree):
                bwd_degs[i] = prev_deg - step
                changed = True

    bwd_degs.reverse()

    # average forward and backward adjustments
    avg_adj = []
    for fwd, bwd in zip(fwd_degs, bwd_degs):
        fwd %= 360
        bwd %= 360
        if abs(fwd - bwd) < 180:
            avg = (fwd + bwd) / 2
        else:
            avg = ((fwd + bwd + 360) / 2) % 360
        avg_adj.append(avg)

    return avg_adj

aspect_lines(radius: float, aspects: list[Aspect]) -> list[str]

Draw aspect lines between aspectable celestial bodies.

Parameters:

Name Type Description Default
radius float

Radius of the aspect wheel

required
aspects list[Aspect]

List of aspects to draw

required

Returns:

Type Description
list[str]

A list of SVG elements representing aspect lines

Source code in natal/chart.py
def aspect_lines(self, radius: float, aspects: list[Aspect]) -> list[str]:
    """Draw aspect lines between aspectable celestial bodies.

    Args:
        radius: Radius of the aspect wheel
        aspects: List of aspects to draw

    Returns:
        A list of SVG elements representing aspect lines
    """
    bg = [
        self.background(
            radius,
            fill=self.config.theme.background,
            stroke=self.config.theme.dim,
            stroke_width=self.config.chart.stroke_width,
        )
    ]
    aspect_lines = []
    for aspect in aspects:
        start_angle = radians(self.data1.normalize(aspect.body1.degree))
        end_angle = radians(self.data1.normalize(aspect.body2.degree))
        orb_config = self.config.orb[aspect.aspect_member.name]
        if not orb_config:
            continue
        orb_fraction = 1 - aspect.orb / orb_config
        opacity_factor = (
            1 if aspect.aspect_member.name == "conjunction" else orb_fraction
        )
        aspect_lines.append(
            line(
                x1=self.cx - radius * cos(start_angle),
                y1=self.cy + radius * sin(start_angle),
                x2=self.cx - radius * cos(end_angle),
                y2=self.cy + radius * sin(end_angle),
                stroke=self.config.theme[aspect.aspect_member.color],
                stroke_width=self.config.chart.stroke_width / 2,
                stroke_opacity=self.config.chart.stroke_opacity * opacity_factor,
            )
        )

    self.aspect_lines_len = len(aspect_lines)  # for test only
    return bg + aspect_lines

background(radius: float, **kwargs) -> str

Create a background circle for the chart.

Parameters:

Name Type Description Default
radius float

Radius of the background circle

required
**kwargs

Additional attributes for the circle element

{}

Returns:

Type Description
str

An SVG circle element representing the background

Source code in natal/chart.py
def background(self, radius: float, **kwargs) -> str:
    """Create a background circle for the chart.

    Args:
        radius: Radius of the background circle
        **kwargs: Additional attributes for the circle element

    Returns:
        An SVG circle element representing the background
    """
    return circle(cx=self.cx, cy=self.cy, r=radius, **kwargs)

body_wheel(wheel_radius: float, data: Data, min_degree: float) -> list[str]

Generate elements for both inner and outer body wheels.

Parameters:

Name Type Description Default
wheel_radius float

Radius of the wheel

required
data Data

Chart data to use

required
min_degree float

Minimum degree separation between bodies

required

Returns:

Type Description
list[str]

A list of SVG elements representing the body wheel

Source code in natal/chart.py
def body_wheel(
    self, wheel_radius: float, data: Data, min_degree: float
) -> list[str]:
    """Generate elements for both inner and outer body wheels.

    Args:
        wheel_radius: Radius of the wheel
        data: Chart data to use
        min_degree: Minimum degree separation between bodies

    Returns:
        A list of SVG elements representing the body wheel
    """

    def norm_deg(x):
        return self.data1.normalize(x.degree)

    sorted_norm_bodies = sorted(data.aspectables, key=norm_deg)
    sorted_norm_degs = [norm_deg(b) for b in sorted_norm_bodies]

    # Calculate adjusted positions
    adj_norm_degs = (
        self.adjusted_degrees(sorted_norm_degs, min_degree)
        if len(sorted_norm_bodies) > 1
        else sorted_norm_degs
    )
    # for tests only
    self.adj_degs_len = len(adj_norm_degs)

    output = []
    for body, adj_deg in zip(sorted_norm_bodies, adj_norm_degs):
        g_opt = {
            "fill": "none",
            "stroke": self.config.theme[body.color],
            "stroke_width": self.config.chart.stroke_width * 1.5,
        }

        # special handling for asc, ic, dsc and mc
        if body.name in VERTEX_NAMES:
            g_opt["fill"] = self.config.theme[body.color]
            g_opt["stroke"] = "none"

        symbol_radius = wheel_radius + (self.ring_thickness / 2)

        # Use original angle for line start position
        original_angle = radians(self.data1.normalize(body.degree))
        degree_x = self.cx - wheel_radius * cos(original_angle)
        degree_y = self.cy + wheel_radius * sin(original_angle)

        # Use adjusted angle for symbol position
        adjusted_angle = radians(adj_deg)
        symbol_x = self.cx - symbol_radius * cos(adjusted_angle)
        symbol_y = self.cy + symbol_radius * sin(adjusted_angle)

        # Add line connecting to the inner circle
        inner_radius = wheel_radius - self.ring_thickness
        inner_x = self.cx - inner_radius * cos(original_angle)
        inner_y = self.cy + inner_radius * sin(original_angle)

        output.extend(
            [
                line(
                    x1=degree_x,
                    y1=degree_y,
                    x2=symbol_x,
                    y2=symbol_y,
                    stroke=self.config.theme[body.color],
                    stroke_width=self.config.chart.stroke_width / 2,
                ),
                circle(
                    cx=symbol_x,
                    cy=symbol_y,
                    r=self.font_size / 2,
                    # fill="red",  # for testing only
                    fill=self.config.theme.background,
                ),
                line(
                    x1=degree_x,
                    y1=degree_y,
                    x2=inner_x,
                    y2=inner_y,
                    stroke=self.config.theme.dim,
                    stroke_width=self.config.chart.stroke_width / 2,
                    stroke_dasharray=self.ring_thickness / 11,
                ),
                g(
                    svg_paths[body.name],
                    transform=f"translate({symbol_x - self.pos_adjustment}, {symbol_y - self.pos_adjustment}) scale({self.scale_adjustment})",
                    **g_opt,
                ),
            ]
        )
    return output

house_wheel() -> list[str]

Generate the house wheel.

Returns:

Type Description
list[str]

A list of SVG elements representing the house wheel

Source code in natal/chart.py
def house_wheel(self) -> list[str]:
    """Generate the house wheel.

    Returns:
        A list of SVG elements representing the house wheel
    """
    radius = self.max_radius - self.ring_thickness
    wheel = [self.background(radius, fill=self.config.theme.background)]

    for i, (start_deg, end_deg) in enumerate(self.house_vertices):
        wheel.append(
            self.sector(
                radius=radius,
                start_deg=start_deg,
                end_deg=end_deg,
                fill=self.bg_colors[i],
                stroke_color=self.config.theme.foreground,
                stroke_width=self.config.chart.stroke_width,
            )
        )

        # Add house number
        number_width = self.font_size * 0.8
        number_radius = radius - (self.ring_thickness / 2)
        number_angle = radians(
            start_deg + ((end_deg - start_deg) % 360) / 2
        )  # Center of the house
        number_x = self.cx - number_radius * cos(number_angle)
        number_y = self.cy + number_radius * sin(number_angle)
        wheel.append(
            text(
                str(i + 1),  # House numbers start from 1
                x=number_x,
                y=number_y,
                fill=getattr(self.config.theme, SIGN_MEMBERS[i].color),
                font_size=number_width,
                text_anchor="middle",
                dominant_baseline="central",
            )
        )

    return wheel

inner_aspect() -> list[str]

Generate aspect lines for the inner wheel in composite charts.

Returns:

Type Description
list[str]

A list of SVG elements representing aspect lines

Source code in natal/chart.py
def inner_aspect(self) -> list[str]:
    """Generate aspect lines for the inner wheel in composite charts.

    Returns:
        A list of SVG elements representing aspect lines
    """
    if self.data2 is None:
        return []
    radius = self.max_radius - 4 * self.ring_thickness
    aspects = self.data1.calculate_aspects(
        self.data1.composite_aspects_pairs(self.data2)
    )
    return self.aspect_lines(radius, aspects)

inner_body_wheel() -> list[str] | None

Generate the inner body wheel for composite charts.

Returns:

Type Description
list[str] | None

A list of SVG elements representing the inner body wheel, or None for single charts

Source code in natal/chart.py
def inner_body_wheel(self) -> list[str] | None:
    """Generate the inner body wheel for composite charts.

    Returns:
        A list of SVG elements representing the inner body wheel, or None for single charts
    """
    if self.data2 is None:
        return
    radius = self.max_radius - 4 * self.ring_thickness
    data = self.data1
    return self.body_wheel(radius, data, self.config.chart.inner_min_degree)

outer_aspect() -> list[str]

Generate aspect lines for the outer wheel in single charts.

Returns:

Type Description
list[str]

A list of SVG elements representing aspect lines

Source code in natal/chart.py
def outer_aspect(self) -> list[str]:
    """Generate aspect lines for the outer wheel in single charts.

    Returns:
        A list of SVG elements representing aspect lines
    """
    if self.data2 is not None:
        return []
    radius = self.max_radius - 3 * self.ring_thickness
    aspects = self.data1.aspects
    return self.aspect_lines(radius, aspects)

outer_body_wheel() -> list[str]

Generate the outer body wheel for single or composite charts.

Returns:

Type Description
list[str]

A list of SVG elements representing the outer body wheel

Source code in natal/chart.py
def outer_body_wheel(self) -> list[str]:
    """Generate the outer body wheel for single or composite charts.

    Returns:
        A list of SVG elements representing the outer body wheel
    """
    radius = self.max_radius - 3 * self.ring_thickness
    data = self.data2 or self.data1
    return self.body_wheel(radius, data, self.config.chart.outer_min_degree)

sector(radius: int, start_deg: float, end_deg: float, fill: str = 'white', stroke_color: str = 'black', stroke_width: float = 1, stroke_opacity: float = 1) -> str

Create a sector shape in SVG format.

Parameters:

Name Type Description Default
radius int

Radius of the sector

required
start_deg float

Starting angle in degrees

required
end_deg float

Ending angle in degrees

required
fill str

Fill color of the sector

'white'
stroke_color str

Stroke color of the sector

'black'
stroke_width float

Width of the stroke

1
stroke_opacity float

Opacity of the stroke

1

Returns:

Type Description
str

An SVG path element representing the sector

Source code in natal/chart.py
def sector(
    self,
    radius: int,
    start_deg: float,
    end_deg: float,
    fill: str = "white",
    stroke_color: str = "black",
    stroke_width: float = 1,
    stroke_opacity: float = 1,
) -> str:
    """Create a sector shape in SVG format.

    Args:
        radius: Radius of the sector
        start_deg: Starting angle in degrees
        end_deg: Ending angle in degrees
        fill: Fill color of the sector
        stroke_color: Stroke color of the sector
        stroke_width: Width of the stroke
        stroke_opacity: Opacity of the stroke

    Returns:
        An SVG path element representing the sector
    """
    start_rad = radians(start_deg)
    end_rad = radians(end_deg)
    start_x = self.cx - radius * cos(start_rad)
    start_y = self.cy + radius * sin(start_rad)
    end_x = self.cx - radius * cos(end_rad)
    end_y = self.cy + radius * sin(end_rad)

    start_x, start_y, end_x, end_y = [
        round(val, 2) for val in (start_x, start_y, end_x, end_y)
    ]

    path_data = " ".join(
        (
            "M{} {}".format(self.cx, self.cy),
            "L{} {}".format(start_x, start_y),
            "A{} {} 0 0 0 {} {}".format(radius, radius, end_x, end_y),
            "Z",
        )
    )
    return path(
        "",
        d=path_data,
        fill=fill,
        stroke=stroke_color,
        stroke_width=stroke_width,
        stroke_opacity=stroke_opacity,
    )

sign_wheel() -> list[str]

Generate the zodiac sign wheel.

Returns:

Type Description
list[str]

A list of SVG elements representing the sign wheel

Source code in natal/chart.py
def sign_wheel(self) -> list[str]:
    """Generate the zodiac sign wheel.

    Returns:
        A list of SVG elements representing the sign wheel
    """
    radius = self.max_radius

    wheel = [self.background(radius=radius, fill=self.config.theme.background)]
    for i in range(12):
        start_deg = self.data1.signs[i].normalized_degree
        end_deg = start_deg + 30
        wheel.append(
            self.sector(
                radius=radius,
                start_deg=start_deg,
                end_deg=end_deg,
                fill=self.bg_colors[i],
                stroke_color=self.config.theme.foreground,
                stroke_width=self.config.chart.stroke_width,
            )
        )
    return wheel

sign_wheel_symbols() -> list[str]

Generate the zodiac sign symbols for the sign wheel.

Returns:

Type Description
list[str]

A list of SVG elements representing the zodiac sign symbols

Source code in natal/chart.py
def sign_wheel_symbols(self) -> list[str]:
    """Generate the zodiac sign symbols for the sign wheel.

    Returns:
        A list of SVG elements representing the zodiac sign symbols
    """

    wheel = []
    for i in range(12):
        start_deg = self.data1.signs[i].normalized_degree
        symbol_radius = self.max_radius - (self.ring_thickness / 2)
        symbol_angle = radians(start_deg + 15)  # Center of the sector
        symbol_x = self.cx - symbol_radius * cos(symbol_angle) - self.pos_adjustment
        symbol_y = self.cy + symbol_radius * sin(symbol_angle) - self.pos_adjustment
        wheel.append(
            g(
                [
                    circle(
                        cx=10,
                        cy=10,
                        r=12,
                        stroke="none",
                        # fill="red",
                        fill=self.bg_colors[i],
                    ),
                    svg_paths[SIGN_MEMBERS[i].name],
                ],
                stroke=self.config.theme[SIGN_MEMBERS[i].color],
                stroke_width=self.config.chart.stroke_width * 1.5,
                fill="none",
                transform=f"translate({symbol_x}, {symbol_y}) scale({self.scale_adjustment})",
            )
        )
    return wheel

svg_root(content: str | list[str]) -> str

Generate an SVG root element with sensible defaults.

Parameters:

Name Type Description Default
content str | list[str]

The content to be included in the SVG root

required

Returns:

Type Description
str

An SVG root element as a string

Source code in natal/chart.py
def svg_root(self, content: str | list[str]) -> str:
    """Generate an SVG root element with sensible defaults.

    Args:
        content: The content to be included in the SVG root

    Returns:
        An SVG root element as a string
    """
    return svg(
        content,
        height=self.height,
        width=self.width,
        font_family=self.config.chart.font,
        version="1.1",
        xmlns="http://www.w3.org/2000/svg",
    )

vertex_wheel() -> list[str]

Generate vertex lines for the chart.

Returns:

Type Description
list[str]

A list of SVG elements representing vertex lines

Source code in natal/chart.py
def vertex_wheel(self) -> list[str]:
    """Generate vertex lines for the chart.

    Returns:
        A list of SVG elements representing vertex lines
    """
    vertex_radius = self.max_radius + self.margin // 2
    house_radius = self.max_radius - 2 * self.ring_thickness
    body_radius = self.max_radius - 3 * self.ring_thickness

    lines = [
        self.background(
            house_radius,
            fill=self.config.theme.background,
            stroke=self.config.theme.foreground,
            stroke_width=self.config.chart.stroke_width,
        ),
        self.background(
            body_radius,
            fill="#88888800",  # transparent
            stroke=self.config.theme.dim,
            stroke_width=self.config.chart.stroke_width,
        ),
    ]
    for house in self.data1.houses:
        radius = house_radius
        stroke_width = self.config.chart.stroke_width
        stroke_color = self.config.theme.dim

        if house.value in [1, 4, 7, 10]:
            radius = vertex_radius
            stroke_color = self.config.theme.foreground

        angle = radians(house.normalized_degree)
        end_x = self.cx - radius * cos(angle)
        end_y = self.cy + radius * sin(angle)

        lines.append(
            line(
                x1=self.cx,
                y1=self.cy,
                x2=end_x,
                y2=end_y,
                stroke=stroke_color,
                stroke_width=stroke_width,
                stroke_opacity=self.config.chart.stroke_opacity,
            )
        )

    return lines