Featured image of post Circles 05

Circles 05

Animated mandala — translucent circles on a polar grid, each orbiting its own spoke.

About

Circles arranged in concentric rings radiating from the canvas centre — 9 rings of 24 spokes each, with alternate rings staggered by half a spoke for a brick-offset pattern. Every circle is randomly displaced from its spoke origin and then animated back into orbit around it, so the whole mandala breathes continuously.

Palette is generated from a single hue seed: three colours evenly spaced around the wheel with a small random jitter, drawn from HSL globals for saturation and luminance. The seed controls the colour family; --seed N produces a different palette and orbit phase for every circle.

Animation uses SVG animateTransform with a negative begin time per circle, placing each one at a different point in its orbit from the first frame. Durations are randomised between 5 s and 15 s.

Generator

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 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
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import math
import sys
import random


SATURATION = 64
LUMINANCE = 50
ALPHA = 0.2

WIDTH = 800
HEIGHT = 450
N_RINGS = 9
N_SPOKES = 24
CENTER_X = WIDTH // 2
CENTER_Y = HEIGHT // 2
MAX_R = min(WIDTH, HEIGHT) // 2 - 24
RING_STEP = MAX_R // N_RINGS
MAX_JITTER = RING_STEP // 6
MIN_DUR = 5.0
MAX_DUR = 15.0

BG = "#1a1a2e"


def palette(hue_seed, n=3, rng=None):
    step = 360 / n
    out = []
    for i in range(n):
        base = (hue_seed + i * step) % 360
        jitter = rng.uniform(-20, 20) if rng else 0
        h = (base + jitter) % 360
        out.append(f"hsl({h:.0f},{SATURATION}%,{LUMINANCE}%)")
    return out


def make_defs():
    return (
        "<defs>\n"
        "  <filter id='sh' x='-30%' y='-30%'"
        " width='160%' height='160%'>\n"
        "    <feDropShadow dx='2' dy='3' stdDeviation='3'"
        " flood-color='#000000' flood-opacity='0.6'/>\n"
        "  </filter>\n"
        "</defs>"
    )


def generate(seed: int = 42) -> str:
    rng = random.Random(seed)
    r = RING_STEP // 2
    r = 24

    hue_seed = rng.uniform(0, 360)
    colors = palette(hue_seed, rng=rng)

    parts = [
        '<svg xmlns="http://www.w3.org/2000/svg"'
        f' viewBox="0 0 {WIDTH} {HEIGHT}"'
        f' width="{WIDTH}" height="{HEIGHT}">',
        f'<style>:root{{width:100%;height:100%;background:{BG}}}</style>',
        make_defs(),
        f'<rect width="{WIDTH}" height="{HEIGHT}" fill="{BG}"/>',
    ]

    points = [(CENTER_X, CENTER_Y)]
    for ring in range(1, N_RINGS + 1):
        ring_r = ring * RING_STEP
        offset = math.pi / N_SPOKES if ring % 2 else 0
        for spoke in range(N_SPOKES):
            angle = 2 * math.pi * spoke / N_SPOKES + offset
            points.append((
                CENTER_X + ring_r * math.cos(angle),
                CENTER_Y + ring_r * math.sin(angle),
            ))

    for ocx, ocy in points:
        dx = rng.uniform(-MAX_JITTER, MAX_JITTER)
        dy = rng.uniform(-MAX_JITTER, MAX_JITTER)
        cx = ocx + dx
        cy = ocy + dy
        color = rng.choice(colors)
        dur = rng.uniform(MIN_DUR, MAX_DUR)
        phase = rng.uniform(0, dur)
        parts.append(
            f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r}"'
            f' fill="{color}" fill-opacity="{ALPHA}"'
            f' stroke="#000000" stroke-width="1.5"'
            f' filter="url(#sh)">'
            '<animateTransform'
            ' attributeName="transform" type="rotate"'
            f' from="0 {ocx:.1f} {ocy:.1f}"'
            f' to="360 {ocx:.1f} {ocy:.1f}"'
            f' dur="{dur:.1f}s"'
            f' begin="-{phase:.1f}s"'
            ' repeatCount="indefinite"/>'
            '</circle>'
        )

    parts.append("</svg>")
    return "\n".join(parts) + "\n"


if __name__ == "__main__":
    import argparse

    p = argparse.ArgumentParser()
    p.add_argument("--output", "-o", default="cover.svg")
    p.add_argument("--seed", type=int, default=82)
    args = p.parse_args()
    svg = generate(seed=args.seed)
    if args.output == "-":
        sys.stdout.write(svg)
    else:
        with open(args.output, "w") as f:
            f.write(svg)
Built with Hugo
Theme Stack designed by Jimmy