Skip to content

Spiral layout

The Archimedean spiral: place units evenly by arc length along an outward spiral.

lexograph.layout.spiral.spiral_layout(n, *, turns=16.0, r0=1.0, r_max=10.0)

Place n units evenly by arc length along an Archimedean spiral.

Parameters:

Name Type Description Default
n int

The number of units to place.

required
turns float

How many full revolutions the spiral makes from r0 to r_max.

16.0
r0 float

The starting radius (the innermost unit sits here).

1.0
r_max float

The outermost radius (the last unit sits near here).

10.0

Returns:

Type Description
Coords

An (n, 2) float array of (x, y) positions, ordered from the

Coords

innermost unit outward.

Raises:

Type Description
ValueError

If n is negative, turns is not positive, or r_max < r0.

Contract
  • Returns exactly n rows.
  • Radius increases monotonically from the first unit to the last.
  • The output is deterministic in its inputs.

Examples:

>>> coords = spiral_layout(5, turns=2.0, r0=1.0, r_max=4.0)
>>> coords.shape
(5, 2)
>>> import numpy as np
>>> radii = np.hypot(coords[:, 0], coords[:, 1])
>>> bool(np.all(np.diff(radii) > 0))
True
Source code in lexograph/layout/spiral.py
def spiral_layout(
    n: int,
    *,
    turns: float = 16.0,
    r0: float = 1.0,
    r_max: float = 10.0,
) -> Coords:
    """Place ``n`` units evenly by arc length along an Archimedean spiral.

    Args:
        n: The number of units to place.
        turns: How many full revolutions the spiral makes from ``r0`` to
            ``r_max``.
        r0: The starting radius (the innermost unit sits here).
        r_max: The outermost radius (the last unit sits near here).

    Returns:
        An ``(n, 2)`` float array of ``(x, y)`` positions, ordered from the
        innermost unit outward.

    Raises:
        ValueError: If ``n`` is negative, ``turns`` is not positive, or
            ``r_max < r0``.

    Contract:
        - Returns exactly ``n`` rows.
        - Radius increases monotonically from the first unit to the last.
        - The output is deterministic in its inputs.

    Examples:
        >>> coords = spiral_layout(5, turns=2.0, r0=1.0, r_max=4.0)
        >>> coords.shape
        (5, 2)
        >>> import numpy as np
        >>> radii = np.hypot(coords[:, 0], coords[:, 1])
        >>> bool(np.all(np.diff(radii) > 0))
        True
    """
    if n < 0:
        msg = f"n must be non-negative, got {n}"
        raise ValueError(msg)
    if turns <= 0:
        msg = f"turns must be positive, got {turns}"
        raise ValueError(msg)
    if r_max < r0:
        msg = f"r_max ({r_max}) must be >= r0 ({r0})"
        raise ValueError(msg)
    if n == 0:
        return np.zeros((0, 2), dtype=float)

    theta_max = 2.0 * np.pi * turns
    b = (r_max - r0) / theta_max
    grid = np.linspace(0.0, theta_max, _ARC_SAMPLES)
    r_grid = r0 + b * grid
    # Arc length ds = sqrt(r^2 + (dr/dtheta)^2) dtheta, with dr/dtheta = b.
    integrand = np.hypot(r_grid, b)
    arc = np.concatenate([[0.0], np.cumsum(np.diff(grid) * integrand[:-1])])
    theta = np.interp(np.linspace(0.0, arc[-1], n), arc, grid)
    r = r0 + b * theta
    x, y = r * np.cos(theta), r * np.sin(theta)
    return np.column_stack([x, y]).astype(float)

lexograph.layout.spiral.tangent_angles(coords)

Return the tangent direction (degrees) of an ordered path at each point.

Computed from the local gradient of the coordinates, this orients per-unit glyphs so they follow the curve (used to set the rotation of each mark on the punctuation spiral).

Parameters:

Name Type Description Default
coords Coords

An (N, 2) array of ordered positions.

required

Returns:

Type Description
FloatArray

A length-N array of tangent angles in degrees.

Raises:

Type Description
ValueError

If coords is not (N, 2).

Examples:

>>> import numpy as np
>>> tangent_angles(np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]])).tolist()
[0.0, 0.0, 0.0]
Source code in lexograph/layout/spiral.py
def tangent_angles(coords: Coords) -> FloatArray:
    """Return the tangent direction (degrees) of an ordered path at each point.

    Computed from the local gradient of the coordinates, this orients per-unit
    glyphs so they follow the curve (used to set the rotation of each mark on
    the punctuation spiral).

    Args:
        coords: An ``(N, 2)`` array of ordered positions.

    Returns:
        A length-``N`` array of tangent angles in degrees.

    Raises:
        ValueError: If ``coords`` is not ``(N, 2)``.

    Examples:
        >>> import numpy as np
        >>> tangent_angles(np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]])).tolist()
        [0.0, 0.0, 0.0]
    """
    array = np.asarray(coords, dtype=float)
    if array.ndim != 2 or array.shape[1] != 2:
        msg = f"coords must have shape (N, 2), got {array.shape}"
        raise ValueError(msg)
    if array.shape[0] == 0:
        return np.zeros((0,), dtype=float)
    dx = np.gradient(array[:, 0])
    dy = np.gradient(array[:, 1])
    return np.degrees(np.arctan2(dy, dx))