Skip to content

geomotif.motifs.graphs

Graph and number art: points on a circle, joined by an arithmetic rule.

Every motif here is the same motif with a different edge rule -- nodes spaced around a circle, and a statement about which pairs get a straight line between them. What makes the family worth a module is how little the rule has to change for the picture to change completely: multiplying by two gives a cardioid, by three a nephroid, and by 51 a figure with no name at all.

The workhorse is :class:ModularMultiplication, the times table drawn as chords. It is also, viewed from the other side, circle string art -- so :mod:geomotif.motifs.stringart re-exports it under that name rather than implementing the same geometry twice.

All of these are :class:~geomotif.SegmentMotif subclasses, so they take merge=True to chain segments that share an endpoint into longer strokes (far fewer pen lifts on a plotter) and show_nodes=True to emit the nodes themselves as loose points.

Classes:

Name Description
CompleteGraph

Every node joined to every other: K5, K12, and the ones in between.

CyclicGraph

The circulant: join every node to the ones a fixed number of steps away.

BipartiteGraph

Two rows of nodes, and every line from one row to the other.

ChordDiagram

Nodes on a circle and whichever chords you name between them.

ModularMultiplication

The times table drawn as chords, which turns out to be a cardioid.

ModularAddition

Join each number to the one a fixed distance further round.

PrimeChords

Join two numbers whenever they add up to a prime.

CompleteGraph dataclass

CompleteGraph(order: int = 12, radius: float = 120.0, rotation: float = pi / 2.0, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = False)

Bases: SegmentMotif

Every node joined to every other: K5, K12, and the ones in between.

The picture mathematicians draw when they say "complete graph", and a surprisingly good ornament -- the chords cross in a moire that tightens towards the middle. The edge count is order * (order - 1) / 2, so it grows quadratically and gets solid black somewhere around forty nodes.

Parameters:

Name Type Description Default
order int

Number of nodes.

12
radius float

Radius of the circle they sit on.

120.0
rotation float

Angle of the first node, in radians. A quarter turn puts it at the top, which is how these are conventionally drawn.

pi / 2.0
center (float, float)

Middle of the circle.

(0.0, 0.0)

Methods:

Name Description
edge_count

Return how many chords this graph draws.

edge_count

edge_count() -> int

Return how many chords this graph draws.

Source code in src/geomotif/motifs/graphs.py
def edge_count(self) -> int:
    """Return how many chords this graph draws."""
    return self.order * (self.order - 1) // 2

CyclicGraph dataclass

CyclicGraph(order: int = 16, steps: tuple[int, ...] = (1, 3, 5), radius: float = 120.0, rotation: float = pi / 2.0, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = False)

Bases: SegmentMotif

The circulant: join every node to the ones a fixed number of steps away.

One step is the plain cycle, which is a regular polygon. Several steps at once is where it gets interesting -- each one contributes its own star polygon and they overlay into a rosette. steps=(1, 2, 3) on a dozen nodes is a good place to start.

Parameters:

Name Type Description Default
order int

Number of nodes.

16
steps tuple of int

How far around to reach. Each step s joins node i to node i + s for every i, wrapping.

(1, 3, 5)
radius float

Radius of the circle the nodes sit on.

120.0
rotation float

Angle of the first node, in radians.

pi / 2.0
center (float, float)

Middle of the circle.

(0.0, 0.0)

BipartiteGraph dataclass

BipartiteGraph(left: int = 4, right: int = 5, span: float = 200.0, height: float = 220.0, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = False)

Bases: SegmentMotif

Two rows of nodes, and every line from one row to the other.

The complete bipartite graph, drawn the way it is drawn in textbooks: two facing ranks with all left * right connections between them. Every crossing is visible, which is what makes it useful for showing why K33 cannot be drawn without them.

Parameters:

Name Type Description Default
left int

Nodes in each rank.

4
right int

Nodes in each rank.

4
span float

Horizontal distance between the two ranks.

200.0
height float

Vertical extent of each rank.

220.0
center (float, float)

Middle of the whole figure.

(0.0, 0.0)

ChordDiagram dataclass

ChordDiagram(order: int = 16, chords: tuple[tuple[int, int], ...] = _EXAMPLE_CHORDS, radius: float = 120.0, rotation: float = pi / 2.0, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = True)

Bases: SegmentMotif

Nodes on a circle and whichever chords you name between them.

The escape hatch of the family, and the one to reach for when the connections come from data rather than from arithmetic -- a dependency graph, a migration table, who talks to whom. The other classes in this module are this one with the chord list computed.

Parameters:

Name Type Description Default
order int

Number of nodes around the circle.

16
chords tuple of (int, int)

Index pairs to join. Self-loops and repeats are dropped rather than rejected, since a real dataset routinely contains both.

_EXAMPLE_CHORDS
radius float

Radius of the circle.

120.0
rotation float

Angle of node zero, in radians.

pi / 2.0
center (float, float)

Middle of the circle.

(0.0, 0.0)
show_nodes bool

Emit the nodes as loose points. On by default here and nowhere else: an arithmetic rule fills the circle densely enough to imply where its nodes are, while a handful of chords from a dataset does not, and without the dots the figure reads as a pile of sticks.

True

ModularMultiplication dataclass

ModularMultiplication(modulus: int = 200, factor: int = 2, radius: float = 120.0, rotation: float = pi, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = False)

Bases: SegmentMotif

The times table drawn as chords, which turns out to be a cardioid.

Space the numbers 0 to modulus - 1 evenly around a circle and join each i to factor * i. Doubling gives a cardioid, tripling a nephroid, and every factor after that an epicycloid with one fewer cusp than the factor -- none of which is put there deliberately. The cusps are the envelope of the chords, and the whole family falls out of one line of arithmetic.

Seen from the other direction this is circle string art, which is why :mod:geomotif.motifs.stringart re-exports it as StringArtCircle rather than drawing the same chords twice.

Parameters:

Name Type Description Default
modulus int

How many numbers go around the circle.

200
factor int

What each is multiplied by. Two for the cardioid, three for the nephroid, and large primes for the dense figures.

2
radius float

Radius of the circle.

120.0
rotation float

Angle of node zero, in radians.

pi
center (float, float)

Middle of the circle.

(0.0, 0.0)

Methods:

Name Description
cusp_count

Return how many cusps the chord envelope has: one fewer than the factor.

cusp_count

cusp_count() -> int

Return how many cusps the chord envelope has: one fewer than the factor.

Source code in src/geomotif/motifs/graphs.py
def cusp_count(self) -> int:
    """Return how many cusps the chord envelope has: one fewer than the factor."""
    return max(1, self.factor - 1)

ModularAddition dataclass

ModularAddition(modulus: int = 120, addend: int = 37, radius: float = 120.0, rotation: float = pi / 2.0, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = False)

Bases: SegmentMotif

Join each number to the one a fixed distance further round.

The times table's sibling, and the plainer of the two: adding a constant steps around the circle at a constant rate, so the result is the star polygon {modulus/addend} -- one loop if the two are coprime, several interleaved ones if they are not.

That makes it the same set of lines :class:~geomotif.motifs.primitives.StarPolygon draws, and it is here because the family reads wrong without it: seeing how little the addition version does is what makes the multiplication version surprising. Reach for StarPolygon when you want the star itself, and for this when you are exploring the arithmetic.

Parameters:

Name Type Description Default
modulus int

How many numbers go around the circle.

120
addend int

How far each step reaches.

37
radius float

Radius of the circle.

120.0
rotation float

Angle of node zero, in radians.

pi / 2.0
center (float, float)

Middle of the circle.

(0.0, 0.0)

Methods:

Name Description
loop_count

Return how many separate loops the walk falls into.

loop_count

loop_count() -> int

Return how many separate loops the walk falls into.

Source code in src/geomotif/motifs/graphs.py
def loop_count(self) -> int:
    """Return how many separate loops the walk falls into."""
    return math.gcd(self.addend % self.modulus, self.modulus)

PrimeChords dataclass

PrimeChords(limit: int = 60, radius: float = 120.0, rotation: float = pi / 2.0, center: Point = (0.0, 0.0), *, merge: bool = False, show_nodes: bool = False)

Bases: SegmentMotif

Join two numbers whenever they add up to a prime.

Space the whole numbers below limit around a circle and draw a chord between every pair whose sum is prime. The result is a dense, oddly orderly web, and every feature in it is a fact about primes rather than a decision about drawing: no chord ever joins two even numbers or two odd ones, because their sum would be even, so the figure is bipartite and the ring of alternating nodes shows it. The one exception is the pair summing to two, which is why zero-to-two is the only even-even chord in the picture.

Parameters:

Name Type Description Default
limit int

Numbers to place around the circle, from 0 to limit - 1. The chord count grows roughly as limit**2 / log(limit), so this gets solid black quickly.

60
radius float

Radius of the circle.

120.0
rotation float

Angle of node zero, in radians.

pi / 2.0
center (float, float)

Middle of the circle.

(0.0, 0.0)