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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ semantic versioning.

### Added

- 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
(keeps `Mr. Bennet` and `"Is it let?" she asked.` whole); `punkt=True` opts
into NLTK Punkt.
- `encode.channels` — the plain-array data contract: `normalize_size`,
`categorical_colors`, `continuous_colors`, and the `Channels` container.
- `layout.linear_layout` — a trivial reading-order grid layout.
- `render` — `render_points` and `render_path` return a matplotlib `Figure`
(no `pyplot`, never `show()`), rendering inline in Jupyter and saving with
`fig.savefig(...)`.
- `examples/spine_demo.py` renders the bundled chapter as a field of sentence
tiles.
- Phase 0 scaffold: package layout mirroring the corpus-lx family (keyflux), uv build
backend, ruff + ty + pytest (`--doctest-modules`) config, CI and publish workflows,
mkdocs documentation skeleton, and the bundled *Pride and Prejudice* demo text
Expand Down
11 changes: 11 additions & 0 deletions docs/api/channels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Encode channels

Map plain per-unit arrays onto visual channels — the package's data contract.

::: lexograph.encode.channels.normalize_size

::: lexograph.encode.channels.categorical_colors

::: lexograph.encode.channels.continuous_colors

::: lexograph.encode.channels.Channels
6 changes: 6 additions & 0 deletions docs/api/layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Layout

Place ordered units in space. The trivial reading-order layout is below; the
walk, spiral, and grid layouts land in later phases.

::: lexograph.layout.linear.linear_layout
7 changes: 7 additions & 0 deletions docs/api/render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Render

Draw laid-out, encoded units as a matplotlib `Figure`.

::: lexograph.render.mpl.render_points

::: lexograph.render.mpl.render_path
11 changes: 11 additions & 0 deletions docs/api/segment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Segment

Turn raw text into ordered units: characters, tokens, or sentences.

::: lexograph.segment.units.segment

::: lexograph.segment.units.sentences

::: lexograph.segment.units.tokens

::: lexograph.segment.units.characters
19 changes: 19 additions & 0 deletions docs/examples/spine_demo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Spine demo

The four steps of the spine — **segment → layout → encode → render** — composed
end to end on the bundled *Pride and Prejudice* text. The chapter's sentences are
laid out as a field of tiles, each tile sized by sentence length and coloured by
its row in the chapter.

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

Run it with:

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

It writes `spine_demo.png` to the current directory. The function returns a
matplotlib `Figure`, so in a notebook you can display it inline instead of saving.
37 changes: 37 additions & 0 deletions examples/spine_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Spine demo: segment -> layout -> encode -> render on the bundled text.

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

from lexograph import (
Channels,
categorical_colors,
linear_layout,
normalize_size,
render_points,
segment,
)
from lexograph.datasets import load_demo_text


def main() -> None:
"""Lay Pride and Prejudice's first chapter out as a field of sentence tiles."""
text = load_demo_text()
units = segment(text, unit="sentences")
print(f"Segmented {len(units)} sentences.")

coords = linear_layout(len(units), columns=8)
channels = Channels(
# Size each tile by sentence length; colour by position in the chapter.
sizes=normalize_size([len(u) for u in units], lo=6.0, hi=26.0, power=1.5),
colors=categorical_colors([i // 8 for i in range(len(units))]),
)

fig = render_points(coords, channels=channels, figsize=(9.0, 9.0))
fig.savefig("spine_demo.png", dpi=120)
print("Saved spine_demo.png")


if __name__ == "__main__":
main()
6 changes: 6 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ nav:
- Getting Started:
- index.md
- quickstart.md
- Examples:
- examples/spine_demo.md
- Troubleshooting: troubleshooting.md
- API Reference:
- api/segment.md
- api/layout.md
- api/channels.md
- api/render.md
- api/datasets.md
17 changes: 17 additions & 0 deletions src/lexograph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,25 @@
__version__ = "0.1.0"

from lexograph.datasets import load_demo_text
from lexograph.encode import (
Channels,
categorical_colors,
continuous_colors,
normalize_size,
)
from lexograph.layout import linear_layout
from lexograph.render import render_path, render_points
from lexograph.segment import segment

__all__ = [
"segment",
"linear_layout",
"normalize_size",
"categorical_colors",
"continuous_colors",
"Channels",
"render_points",
"render_path",
"load_demo_text",
"__version__",
]
16 changes: 16 additions & 0 deletions src/lexograph/encode/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
"""Encode: map per-unit attributes onto visual channels (size, colour, glyph)."""

from lexograph.encode.channels import (
RGBA,
Channels,
categorical_colors,
continuous_colors,
normalize_size,
)

__all__ = [
"RGBA",
"Channels",
"normalize_size",
"categorical_colors",
"continuous_colors",
]
164 changes: 164 additions & 0 deletions src/lexograph/encode/channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Map per-unit attributes onto visual channels — the package's data seam.

Every function here accepts a **plain per-unit array** and returns matplotlib-ready
values. ``size`` comes from a scalar array; ``colour`` comes from either an array
of category labels (mapped to a qualitative palette) or an array of numeric values
(mapped through a continuous colormap). Nothing here knows where the numbers came
from, which is exactly what lets a caller drive a figure from ``length``,
``frequency``, a graph centrality, or their own column.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING

import matplotlib as mpl
import numpy as np
from matplotlib.colors import Normalize

from lexograph._types import FloatArray

if TYPE_CHECKING:
from collections.abc import Iterable

__all__ = [
"RGBA",
"Channels",
"normalize_size",
"categorical_colors",
"continuous_colors",
]

RGBA = tuple[float, float, float, float]
"""A matplotlib ``(red, green, blue, alpha)`` colour, each component in ``[0, 1]``."""


def normalize_size(
values: Iterable[float],
*,
lo: float = 6.0,
hi: float = 24.0,
power: float = 1.0,
) -> FloatArray:
"""Scale a per-unit scalar array into a size range.

The values are min-max normalised to ``[0, 1]``, raised to ``power`` (a
``power`` above 1 compresses the low end so only the largest units stand
out, mirroring the PageRank-driven sizing in the Wittgenstein piece), then
mapped onto ``[lo, hi]``.

Args:
values: One scalar per unit (e.g. sentence length or a centrality).
lo: The smallest output size.
hi: The largest output size.
power: Exponent applied to the normalised values before scaling.

Returns:
A float array of sizes, one per input value. A constant or empty input
maps every unit to ``lo``.

Raises:
ValueError: If ``hi < lo`` or ``power`` is not positive.

Examples:
>>> normalize_size([0, 5, 10], lo=1.0, hi=3.0).tolist()
[1.0, 2.0, 3.0]
"""
if hi < lo:
msg = f"hi ({hi}) must be >= lo ({lo})"
raise ValueError(msg)
if power <= 0:
msg = f"power must be positive, got {power}"
raise ValueError(msg)
array = np.asarray(list(values), dtype=float)
if array.size == 0:
return array
vmin, vmax = float(array.min()), float(array.max())
if vmax == vmin:
norm = np.zeros_like(array)
else:
norm = (array - vmin) / (vmax - vmin)
return lo + (hi - lo) * norm**power


def categorical_colors(
labels: Sequence[object], *, cmap: str | None = None
) -> list[RGBA]:
"""Map category labels to a stable qualitative palette.

Labels are coloured in order of first appearance, so the same label always
gets the same colour and adjacent distinct labels are visually separated.
The default palette follows the Wittgenstein piece: ``tab20`` for up to 20
categories, evenly spaced ``hsv`` beyond that.

Args:
labels: One hashable label per unit (e.g. a cluster or community id).
cmap: Override colormap name. ``None`` selects the default by count.

Returns:
One RGBA colour per input label.

Examples:
>>> colors = categorical_colors([0, 1, 0, 2])
>>> len(colors)
4
>>> colors[0] == colors[2]
True
"""
order: dict[object, int] = {}
for label in labels:
if label not in order:
order[label] = len(order)
count = len(order)
if cmap is None:
cmap = "tab20" if count <= 20 else "hsv"
colormap = mpl.colormaps[cmap]
if count <= 20 and cmap == "tab20":
palette = [colormap(i) for i in range(count)]
else:
palette = [colormap(i / max(count, 1)) for i in range(count)]
return [palette[order[label]] for label in labels]


def continuous_colors(values: Iterable[float], *, cmap: str = "viridis") -> list[RGBA]:
"""Map a per-unit numeric array through a continuous colormap.

Args:
values: One scalar per unit.
cmap: A continuous matplotlib colormap name.

Returns:
One RGBA colour per value. A constant or empty input maps every unit to
the colormap's midpoint.

Examples:
>>> colors = continuous_colors([0.0, 0.5, 1.0])
>>> len(colors)
3
"""
array = np.asarray(list(values), dtype=float)
colormap = mpl.colormaps[cmap]
if array.size == 0:
return []
vmin, vmax = float(array.min()), float(array.max())
if vmax == vmin:
return [colormap(0.5) for _ in array]
norm = Normalize(vmin=vmin, vmax=vmax)
return [colormap(float(norm(v))) for v in array]


@dataclass(frozen=True, slots=True)
class Channels:
"""Resolved visual channels, one entry per unit.

Attributes:
sizes: Per-unit size in points, or ``None`` for a uniform size.
colors: Per-unit RGBA colour, or ``None`` for a single default colour.
glyphs: Per-unit text to draw, or ``None`` to draw markers/segments.
"""

sizes: FloatArray | None = None
colors: list[RGBA] | None = None
glyphs: list[str] | None = None
4 changes: 4 additions & 0 deletions src/lexograph/layout/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""Layout: place ordered units in 2-D or 3-D space (walks, spirals, grids)."""

from lexograph.layout.linear import linear_layout

__all__ = ["linear_layout"]
Loading
Loading