Walldye

Times table

inspired by Mathologer, Times Tables, Mandelbrot and the Heart of Mathematics1

Joining each point on a circle to the point at twice its number traces a heart-shaped curve, a cardioid.

Made with Claude Opus 5.5

Technique
line art
Shape
Any screen
Added
27 September 2026

Export

Shape
cropped from 16:9
Across
Down
Scale
Crop
Size
Format

Colors

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Notes

Number the nails 0 to 199 and run a string from each nail n to nail 2n, counting past 199 round from 0 again. Every string touches the cardioid at one point, so the curve appears where the strings crowd together.

Sources

  1. Mathologer, Times Tables, Mandelbrot and the Heart of Mathematics. ↑
  2. Cardioid.

Source code

wallpapers/times-table/design.py, 103 lines

"""Times-table string art: each nail on a ring is strung to the nail at a multiple of its number."""

import math
from itertools import pairwise

import numpy as np

from walldye import (
    ACCENT,
    BG,
    BG_ALT,
    UI,
    UI_ALT,
    UI_HI,
    Canvas,
    Color,
    P,
    Params,
    Vec,
    design,
    knob,
    mix,
    polar,
    smoothstep,
)
from walldye.field import gauss


class Table(Params):
    multiplier: int = knob(
        default=2, lo=2, hi=9, doc="nail i is strung to nail multiplier * i (mod the nail count)"
    )


R, N = 380, 200  # ring radius and nail count
SEG = 8  # px between brightness samples along a string
GLOW = 90  # px falloff of brightness away from a string's tangent point
LV, HV = 12, 6  # brightness and warmth levels
KNEE = 0.6  # brightness level that maps to UI; only strings near the lit cusp may exceed it


def tone(lv: int, hv: int) -> Color:
    """Brightness level `lv`, from near BG through UI to between UI_ALT and UI_HI, blended
    toward the accent ramp by warmth level `hv`."""
    g = lv / (LV - 1)
    lo, hi = mix(BG, BG_ALT, 0.7), mix(UI_ALT, UI_HI, 0.4)
    cold = mix(lo, UI, g / KNEE) if g < KNEE else mix(UI, hi, (g - KNEE) / (1 - KNEE))
    return mix(cold, mix(mix(BG, ACCENT, 0.18), ACCENT, g), hv / (HV - 1))


TONES = tuple(tone(lv, hv) for lv in range(LV) for hv in range(HV))  # index lv * HV + hv


@design(aspects="any")
def draw(s: Canvas[Table]) -> None:
    k = s.params.multiplier
    # landscape: right of center, the cusp facing the empty side; portrait: under the clock
    c = s.pick(landscape=(0.651, 0.5), portrait=(0.5, 0.42))
    o = Vec(0, 0)  # the ring's center, at c
    # The envelope is an epicycloid with k - 1 cusps on a circle of radius R (k - 1) / (k + 1).
    # An even k puts one opposite nail 0; an odd k turns the ring half a cusp gap so one faces left.
    turn = 0.0 if k % 2 == 0 else -math.pi / (k - 1)
    cusp = polar(o, R * (k - 1) / (k + 1), rad=math.pi)

    with s.motif(c, R + 20):  # the long nails and their stroke
        s.stroke(P().circle(o, R), BG_ALT, 1.4)
        nails = P()
        for i in range(N):
            a = 2 * math.pi * i / N + turn
            nails.M(polar(o, R + 8, rad=a)).L(polar(o, R + (18 if i % 10 == 0 else 13), rad=a))
        s.stroke(nails, UI, 1.3)

        # Strings through the lit cusp stack into a solid wedge until they fan apart, so there
        # they are drawn thinner, under the rest.
        with (
            s.buckets(TONES, "stroke", stroke_width=0.8) as thin,
            s.buckets(TONES, "stroke", stroke_width=1.1) as thick,
        ):
            for i in range(1, N):
                th = 2 * math.pi * i / N
                a, b = polar(o, R, rad=th + turn), polar(o, R, rad=k * th + turn)
                if abs(b - a) < 1:
                    continue
                # where this string touches the envelope
                t = (a * k + b) / (k + 1)
                dc = abs(t - cusp)
                warm = gauss(dc, 150)
                ceil = KNEE + (1 - KNEE) * gauss(dc, 190)
                near = gauss(dc, 14)
                glow = GLOW * (0.55 + 0.45 * smoothstep(0, 70, dc))
                n = max(1, int(abs(b - a) / SEG))
                pts = np.asarray(a) + np.outer(np.arange(n + 1) / n, np.asarray(b - a))
                mid = (pts[:-1] + pts[1:]) / 2
                v = gauss(np.hypot(*(mid - t).T), glow)
                v *= 1 - 0.75 * near * (1 - smoothstep(6, 50, np.hypot(*(mid - cusp).T)))
                lv = np.minimum(LV - 1, (np.minimum(v, ceil) * LV).astype(int))
                # only the lit part of a string warms up, so long strings stay cold across the disc
                hv = np.minimum(HV - 1, (warm * np.minimum(1, v * 2.5) * HV).astype(int))
                key = lv * HV + hv
                fine = (dc < 20) & (hv > 0)
                cut = np.flatnonzero((np.diff(key) != 0) | (np.diff(fine) != 0)) + 1
                for j0, j1 in pairwise((0, *cut, n)):
                    (thin if fine[j0] else thick)[key[j0]].poly(pts[[j0, j1]])

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render times-table --theme fireproof -o times-table-fireproof-16x9.svg