Times table
inspired by Mathologer, Times Tables, Mandelbrot and the Heart of Mathematics1
Two hundred nails on a ring, each strung to double its number. The strings trace a cardioid, lit at its cusp.
Made with Claude Opus 5.5
- Technique
- line art
- Shape
- Any screen
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
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 and is drawn brightest there, so the curve shows where the strings crowd together.
Sources
Source code
wallpapers/times-table/design.py, 100 lines
"""Times-table string art: each nail on a ring is strung to the nail k times its number, and the strings are lit where their envelope folds into a cusp."""
import math
from itertools import pairwise
import numpy as np
from walldye import (
ACCENT,
BG,
BG_ALT,
UI,
UI_ALT,
UI_HI,
Canvas,
Colour,
P,
Params,
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) -> Colour:
"""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 centre, the cusp facing the empty side; portrait: under the clock
c = s.pick(landscape=(0.651, 0.5), portrait=(0.5, 0.42))
# 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(c, R * (k - 1) / (k + 1), rad=math.pi)
s.stroke(P().circle(c, R), BG_ALT, 1.4)
nails = P()
for i in range(N):
a = 2 * math.pi * i / N + turn
nails.M(polar(c, R + 8, rad=a)).L(polar(c, 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(c, R, rad=th + turn), polar(c, 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]])"""Times-table string art: each nail on a ring is strung to the nail k times its number, and the strings are lit where their envelope folds into a cusp."""
import math
from itertools import pairwise
import numpy as np
from walldye import (
ACCENT,
BG,
BG_ALT,
UI,
UI_ALT,
UI_HI,
Canvas,
Colour,
P,
Params,
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) -> Colour:
"""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 centre, the cusp facing the empty side; portrait: under the clock
c = s.pick(landscape=(0.651, 0.5), portrait=(0.5, 0.42))
# 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(c, R * (k - 1) / (k + 1), rad=math.pi)
s.stroke(P().circle(c, R), BG_ALT, 1.4)
nails = P()
for i in range(N):
a = 2 * math.pi * i / N + turn
nails.M(polar(c, R + 8, rad=a)).L(polar(c, 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(c, R, rad=th + turn), polar(c, 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