Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ semantic versioning.

### Added

- Phase 2 walk + spiral: the first two presets and their layouts.
- `layout.walk_layout` / `heading_angles` — the 2-D turtle walk (step forward,
turn 90°), ported from the Wittgenstein piece's `compute_path`.
- `layout.spiral_layout` / `tangent_angles` — equal-arc-length placement along
an Archimedean spiral.
- `layout.rendered_widths` — headless rendered-width measurement (matplotlib
`TextPath`), the width-step that drives the walk.
- `text_walk` preset — sentences as a space-filling walk, with `"path"` and
`"glyphs"` (calligraphy-on-path) modes; built-in length/position channels.
- `punctuation_spiral` preset — a text's marks on a spiral, accenting logical,
mathematical, and Greek signs in gold.
- `render.frame_axes` helper; `examples/` and docs for both presets.
- Phase 1 spine: the four-step pipeline end to end.
- `segment` — split text into characters, tokens, or sentences. Sentence
splitting defaults to a self-contained, abbreviation-aware offline splitter
Expand Down
7 changes: 7 additions & 0 deletions docs/api/punctuation_spiral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Punctuation spiral

A text's punctuation and logical signs wound onto an Archimedean spiral.

::: lexograph.presets.punctuation_spiral.punctuation_spiral

::: lexograph.presets.punctuation_spiral.is_accent
7 changes: 7 additions & 0 deletions docs/api/spiral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Spiral layout

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

::: lexograph.layout.spiral.spiral_layout

::: lexograph.layout.spiral.tangent_angles
5 changes: 5 additions & 0 deletions docs/api/text_walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Text walk

Each sentence steps forward and turns, space-filling — the 2-D walk.

::: lexograph.presets.text_walk.text_walk
7 changes: 7 additions & 0 deletions docs/api/walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Walk layout

The turtle walk: step forward by a per-unit length, then turn.

::: lexograph.layout.walk.walk_layout

::: lexograph.layout.walk.heading_angles
6 changes: 6 additions & 0 deletions docs/api/widths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Rendered widths

Measure each unit's rendered text width headlessly — the width-step that drives
the walk.

::: lexograph.layout.widths.rendered_widths
15 changes: 15 additions & 0 deletions docs/examples/punctuation_spiral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Punctuation spiral

Every non-alphanumeric mark of the bundled chapter, in reading order, wound onto
an outward Archimedean spiral. Ordinary punctuation is dim warm grey; logical,
mathematical, and Greek signs are picked out in gold.

```python
--8<-- "examples/punctuation_spiral_demo.py"
```

Run it with:

```bash
uv run python examples/punctuation_spiral_demo.py
```
18 changes: 18 additions & 0 deletions docs/examples/text_walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Text walk

The bundled chapter as a space-filling turtle walk: each sentence steps forward
by its rendered width and turns 90°. Each step is coloured by its position in the
chapter and weighted by sentence length.

```python
--8<-- "examples/text_walk_demo.py"
```

Run it with:

```bash
uv run python examples/text_walk_demo.py
```

Pass `mode="glyphs"` to set each sentence's text along its segment
(calligraphy-on-path) instead of drawing a coloured ribbon.
30 changes: 26 additions & 4 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,31 @@ print(text[:52])
# It is a truth universally acknowledged, that a singl
```

## Draw a preset

Each preset segments the text, lays the units out, encodes per-unit attributes, and
returns a matplotlib `Figure`.

```python
from lexograph import load_demo_text, punctuation_spiral, text_walk

text = load_demo_text()

# Every punctuation mark and logical sign, wound onto an Archimedean spiral.
spiral = punctuation_spiral(text)
spiral.savefig("punctuation_spiral.png", dpi=150)

# Each sentence as a step of a space-filling turtle walk, coloured by position.
walk = text_walk(text)
walk.savefig("text_walk.png", dpi=150)
```

Both return a `Figure` and never call `show()`, so they display inline in Jupyter and
save cleanly from a script. The `text_walk` colour and size channels accept any
per-sentence array — `length`, `frequency`, a community id, or your own column — which
is the [data contract](index.md#the-data-contract) that keeps lexograph general.

## Next steps

The preset front ends (punctuation spiral, text walk, recurrence dotplot, concordance)
land over the following build phases. Each one segments this text, lays the units out,
encodes per-unit attributes, and returns a matplotlib `Figure` you can save with
`fig.savefig(...)` or display inline in Jupyter.
The remaining presets (3-D walk, recurrence dotplot, concordance) land over the
following build phases, each on the same spine.
18 changes: 18 additions & 0 deletions examples/punctuation_spiral_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Punctuation-spiral demo: a text's marks wound onto an Archimedean spiral.

Run with: uv run python examples/punctuation_spiral_demo.py
Writes punctuation_spiral.png to the current directory.
"""

from lexograph import load_demo_text, punctuation_spiral


def main() -> None:
"""Draw the punctuation of Pride and Prejudice's first chapter as a spiral."""
fig = punctuation_spiral(load_demo_text(), turns=12.0)
fig.savefig("punctuation_spiral.png", dpi=150)
print("Saved punctuation_spiral.png")


if __name__ == "__main__":
main()
24 changes: 24 additions & 0 deletions examples/text_walk_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Text-walk demo: a chapter as a space-filling turtle walk over its sentences.

Run with: uv run python examples/text_walk_demo.py
Writes text_walk.png to the current directory.
"""

from lexograph import load_demo_text, segment, text_walk


def main() -> None:
"""Walk the bundled chapter, colouring each step by its position in the text."""
text = load_demo_text()
units = segment(text, unit="sentences")
print(f"Walking {len(units)} sentences.")

# Colour each sentence by where it falls in the chapter (continuous), and let
# the default size channel weight each stroke by sentence length.
fig = text_walk(text, colour=list(range(len(units))), figsize=(10.0, 10.0))
fig.savefig("text_walk.png", dpi=150)
print("Saved text_walk.png")


if __name__ == "__main__":
main()
19 changes: 14 additions & 5 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,20 @@ nav:
- index.md
- quickstart.md
- Examples:
- examples/punctuation_spiral.md
- examples/text_walk.md
- examples/spine_demo.md
- Troubleshooting: troubleshooting.md
- API Reference:
- api/segment.md
- api/layout.md
- api/channels.md
- api/render.md
- api/datasets.md
- Presets:
- api/punctuation_spiral.md
- api/text_walk.md
- Spine:
- api/segment.md
- api/layout.md
- api/walk.md
- api/spiral.md
- api/widths.md
- api/channels.md
- api/render.md
- api/datasets.md
16 changes: 14 additions & 2 deletions src/lexograph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,31 @@
continuous_colors,
normalize_size,
)
from lexograph.layout import linear_layout
from lexograph.render import render_path, render_points
from lexograph.layout import (
linear_layout,
rendered_widths,
spiral_layout,
walk_layout,
)
from lexograph.presets import punctuation_spiral, text_walk
from lexograph.render import frame_axes, render_path, render_points
from lexograph.segment import segment

__all__ = [
"segment",
"linear_layout",
"walk_layout",
"spiral_layout",
"rendered_widths",
"normalize_size",
"categorical_colors",
"continuous_colors",
"Channels",
"render_points",
"render_path",
"frame_axes",
"punctuation_spiral",
"text_walk",
"load_demo_text",
"__version__",
]
12 changes: 11 additions & 1 deletion src/lexograph/layout/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
"""Layout: place ordered units in 2-D or 3-D space (walks, spirals, grids)."""

from lexograph.layout.linear import linear_layout
from lexograph.layout.spiral import spiral_layout, tangent_angles
from lexograph.layout.walk import heading_angles, walk_layout
from lexograph.layout.widths import rendered_widths

__all__ = ["linear_layout"]
__all__ = [
"linear_layout",
"walk_layout",
"heading_angles",
"spiral_layout",
"tangent_angles",
"rendered_widths",
]
115 changes: 115 additions & 0 deletions src/lexograph/layout/spiral.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""The Archimedean spiral layout: place units evenly along an outward spiral.

Ported from the Wittgenstein piece's ``spiral_xy``. Units are spread by equal
*arc length* (not equal angle) along the Archimedean spiral ``r = r0 + b·theta``,
so the spacing between consecutive units stays visually uniform as the spiral
winds outward. This is the layout behind the punctuation-spiral preset, where
each non-alphanumeric mark is a unit.
"""

from __future__ import annotations

import numpy as np

from lexograph._types import Coords, FloatArray

__all__ = ["spiral_layout", "tangent_angles"]

# Resolution of the arc-length integration grid. Large enough that the
# equal-arc-length resampling is smooth for any realistic unit count.
_ARC_SAMPLES = 200_000


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)


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))
Loading