Rivers (pathfinding)#
A river is a path of whitespace between two kept words. There are two strategies, plus the building blocks they're made of. See Concepts → Rivers for the idea.
Both between and flow produce polylines in page-pixel coordinates; feed the
result to H.geom.channel to turn it into a
ribbon.
H.river.between(a, b)#
Dijkstra over the precomputed whitespace graph.
Picks the best entry/exit ports on each word's dock, finds the shortest gutter
path between them, and returns a ChannelSegment — or null if either word
lacks a dock or no path connects them.
interface ChannelSegment {
points: Pt[]; // ordered polyline through the whitespace
gutterIds: number[]; // gutterIds[i] is the gutter used from points[i] to points[i+1]
}
The per-segment gutterIds let a renderer vary stroke width by the gutter's
minWidth (see channel).
const seg = H.river.between(wordA, wordB);
const ribbon = seg ? H.geom.channel(seg) : null; // → Pt[], fill as a closed path
H.river.flow(a, b, opts)#
A greedy walk through a Perlin noise field, biased toward the target and
away from obstacles. Organic, and a good fallback when between returns null
(graph islands). Note it takes points (Pt), not words, and always returns a
polyline (it appends the endpoint even if it stalls).
The options object itself is required — pass at least {} — but every field
inside it is optional. In particular, unlike the raw flow export, body is
optional here: it defaults to the page's body bbox, or the full page if there
isn't one.
FlowOptions:
| Field | Type | Default | Notes |
|---|---|---|---|
seed |
number? |
0 |
Noise seed. |
stepSize |
number? |
2.5 |
Px per step (smaller = smoother). |
maxSteps |
number? |
600 |
Hard step cap. |
noiseFreq |
number? |
0.007 |
Spatial scale of the noise field. |
targetWeight |
number? |
0.45 |
Bias toward target (0 = pure flow, 1 = straight line). |
candidateCount |
number? |
24 |
Directions evaluated per step. |
obstaclePad |
number? |
1 |
Padding around obstacles (px). |
body |
Bbox |
page body | Box the path must stay inside (optional via H.river.flow). |
obstacles |
Bbox[]? |
[] |
Boxes to avoid (typically non-selected words). |
// flow works in points — take each word's centre:
const centre = (w) => ({ x: (w.x0 + w.x1) / 2, y: (w.y0 + w.y1) / 2 });
const path = H.river.flow(centre(wordA), centre(wordB), {
seed: 7,
obstacles: H.river.obstaclesFrom(H.words, selectedIds),
});
Building blocks#
H.river.pickPorts(a, b)#
Chooses the (portA, portB) pair whose compass directions flow most directly
from A toward B. Returns null if either word has no dock/ports. between uses
this internally.
H.river.penalizeBorders(margin?, penalty?)#
Returns a new graph whose edges that hug the body margins have inflated cost,
so Dijkstra paths avoid crawling along the page edges. Feed the result to
H.river.dijkstra if you want that behaviour.
H.river.dijkstra(graph, start, end)#
H.river.dijkstra(graph: PageGraph, start: number, end: number):
{ nodeIds: number[]; gutterIds: number[] }
The raw shortest-path over graph node ids. Returns empty arrays if end is
unreachable.
H.river.obstaclesFrom(words, selectedIds, pad?)#
Convenience: the bounding boxes of every word except the selected ids
(padded), ready to pass as flow's obstacles — i.e. "route around everything
I'm not keeping".