Path graph: run the lane-change curve corner-free so it reads thin

The lane change kept a flat platform each side of the curve, where the nodes
sat. But a platform (slope 0) meets the climbing curve at a corner, and a
corner draws a heavy braille knee — a cell filled 3-4 dots — that reads thick,
the more so the shallower the curve's end tangent. Flattening the S last time
(BEND_K 0.5 -> 0.2) thinned the middle but sharpened that corner, trading a
thick centre for a thick knee: the platform model can't make both thin.

Drop the platforms: span the curve across the whole column gap (CURVE_SPAN
0.9 -> 1.0) so it runs continuously node to node with no corner to pile a
knee, and keep the end tangents level (BEND_K 0.2 -> 0.4) so each marker still
seats on a flat point without a platform run. The knees become gentle
rise-and-settle arcs and the stroke stays even and thin end to end. The honest
cost: in a very tight column a full-lane shift is steep right to the node, so
its marker sits on a slight slope rather than dead level — thinness bought with
a hair of level seating, the trade this screen wants.

Verified by rasterising to the dot grid: the curves are already 1-pixel-wide
(a morphological thinning pass removes zero dots from them), so the remaining
weight was never removable fat — it was the corner, now gone. What density is
left sits at endpoint convergence (many routes merging), which is not a lane
change and is left alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-22 21:55:03 -04:00
1 parent a6b5405fdc
commit c008fe56d2
1 file changed
+46 -45
+46 -45
View File
@@ -11,9 +11,9 @@ their own course, and **converge** back into us, sharing a relay wherever their
Earlier drawings tried a vertical *bus* the lanes tapped at right angles (a metro map you
wander around, the bare 90° turns hiding that A flows to B), then oblique branches straight
off each marker (which left every off-lane relay a pointed *peak* or *valley*). The widget
now draws the fan as a **multilane highway**: a node always sits on a level platform in its
lane, and a route changes lane only *between* nodes, easing across on a single gentle shift
the way a car drifts one lane over and then stays there. Concretely:
now draws the fan as a **multilane highway**: a node sits level in its lane, and a route changes
lane only *between* nodes, easing across on a single gentle shift the way a car drifts one lane
over and then settles. Concretely:
* **lanes** — the highest-*priority* path (the spine, e.g. the best-evidence route) holds the
flow's centre, its relays in a straight run, and the alternatives fan above and below it a
@@ -36,14 +36,16 @@ the way a car drifts one lane over and then stays there. Concretely:
* **columns** (x) place each node by *balanced* rank — its distance from the origin over its
distance-plus-remaining-distance to us — so a path's relays spread evenly between the two
ends however long the other paths are, and a shared relay lands in one place;
* **platforms** — every node is entered and left along a level run in its own lane, so a
relay that sits off its neighbours' lane reads as a flat-topped *trapezium*, never a
pointed peak or valley. Between two nodes on different lanes the line stays level out of the
first, makes one oblique shift, and runs level into the second — the only corners are the
soft level→oblique bends of the shift, never a bare right angle in open canvas;
* **level seating** — every node is entered and left on a *level tangent*, so a relay that sits
off its neighbours' lane reads as a gentle rise-and-settle, never a pointed peak or valley.
Between two nodes on different lanes the shift leaves the first level, drifts across, and
arrives level into the second — the only bends are the soft level→curve→level eases, never a
bare right angle in open canvas. The curve spans the whole gap rather than a centred stretch
flanked by flat platforms: a platform-to-curve corner draws a heavy braille *knee*, so the
stroke runs continuously node to node and stays an even, thin arc (see :data:`_CURVE_SPAN`);
* **shared relays** draw as a single marker (a route re-using a hop is not a new node): the
marker sits on its highest-priority path's lane, and a lower path that also rides it leans
off its lane to meet the platform and back — which reads as the alternative *branching
off its lane to meet the marker and back — which reads as the alternative *branching
through the shared node*, exactly the story the evidence tells;
* **diverge / converge** — the fan at each end is flow, not a switchboard: routes leave the
origin overlapping on the centre line and peel off one by one to their lanes (the
@@ -97,32 +99,32 @@ _CELL_MID_DOT = 2
#: Dot-space margin the endpoint markers keep from the canvas edges.
_GRAPH_PAD_DOTS = 6
#: The share of a lane change's horizontal column gap spent on the eased curve itself — the
#: rest is split into the two level platforms that flank it, where the nodes sit. At ``1.0`` the
#: whole gap curves (the platforms vanish and successive shifts run into one another); a smaller
#: share lengthens the flat platforms and steepens the (now shorter) curve between them. Sizing
#: the curve as a fraction of the gap — rather than by a fixed slope keyed off the vertical
#: offset — keeps the platform-to-curve proportion constant however wide or narrow the columns
#: fall. Kept generous so the curve has room to stay shallow: a braille cell is 2 dots wide by
#: 4 tall, so a stroke steeper than one dot across per dot down must light two dots in a cell to
#: stay joined and reads *thick* — giving the curve more horizontal run keeps its slope, and so
#: its stroke, thin.
_CURVE_SPAN = 0.9
#: The share of a lane change's horizontal column gap spent on the eased curve itself — the rest
#: would split into two flat platforms flanking it. Set to the whole gap: the curve runs
#: continuously from one node to the next with *no* separate flat segment. A shorter curve leaves
#: flat platforms but reintroduces a **corner** where a platform (slope 0) meets the climbing
#: curve, and that corner piles a heavy braille *knee* (a cell filled 3–4 dots) that reads thick —
#: the more so the shallower the curve's end tangent. Spanning the whole gap removes the corner,
#: so the stroke stays an even, thin arc end to end; the nodes still seat level because the curve's
#: end tangents are kept horizontal (see :data:`_BEND_K`), giving each marker a flat point to sit on
#: without a platform run. (In a very tight column a full-lane shift is unavoidably steep right up to
#: the node, so its marker sits on a gentle slope rather than dead level — the honest cost of keeping
#: the stroke thin there.)
_CURVE_SPAN = 1.0
#: The shortest horizontal run a lane change is given even for a one-lane hop, so a tight
#: column gap still bends across a few dots rather than snapping over in one abrupt step.
_MIN_SHIFT_DOTS = 4
#: How far (as a fraction of the shift's horizontal span) the bezier control points sit in
#: from each end — both placed level with their own end, so the curve leaves and enters the
#: platforms horizontally. Near ½ the S is at its roundest, but a round S is also its *thickest*:
#: pinning both tangents flat concentrates the whole vertical change into a steep middle that
#: lights two-plus dots per braille cell (a heavy knee), while the shallow ends stay thin — so
#: one shift reads as thin at its ends and thick through its centre. A low value straightens the
#: S toward a constant-slope diagonal (only its corners eased), spreading the drop evenly so the
#: stroke is as thin as the geometry allows along its whole length; too low brings the corners
#: back as visible kinks. Tuned low for an even, thin stroke that still eases off the platforms.
_BEND_K = 0.2
#: How far (as a fraction of the shift's horizontal span) the bezier control points sit in from
#: each end — both placed level with their own end, so the curve leaves and enters each node
#: horizontally. This flat end tangent is what seats a node level now that the curve spans the
#: whole gap (:data:`_CURVE_SPAN`) with no separate platform. It trades against thinness: a higher
#: value holds the tangent flatter for longer (a better-seated node) but steepens the curve's
#: middle to compensate (a thicker centre), while a lower value spreads the drop more evenly
#: (thinner) but tilts the node's tangent (a marker on a slope). Tuned to the balance that keeps
#: the marker's seating close to level while the stroke stays an even, thin arc between nodes.
_BEND_K = 0.4
#: Dots left of our marker the flow arrow sits — one cell, so it embeds in the trunk as ``▶★``
#: and marks the node → us direction without crowding the endpoint.
@@ -510,19 +512,18 @@ def _route(
) -> list[tuple[float, float]]:
"""The point chain for one edge (dot coordinates), drawn as multilane-highway flow.
Two nodes on the same lane join with a level run; two on different lanes join with a
*trapezium* — level out of the first marker, one smooth **shift** across the intervening
lanes (a bezier lane change, level where it meets each platform; see :func:`_sbend`), then
level into the second — so both nodes sit on a flat platform and there is no corner
anywhere, only the eased level→curve→level of the shift. The shift takes a fixed share
:data:`_CURVE_SPAN` of the column gap (a gentle drift, not a slope keyed off the drop) and
sits centred in it, so the platforms flank it evenly; when the gap is too tight to hold a
curve of even :data:`_MIN_SHIFT_DOTS`, the bend simply spans the whole gap. The endpoints,
sitting at the centre of the lane band,
make the origin's diverging peels and us's converging merges fall out of this one rule —
no endpoint special case. The lone exception is ``bidir``: a pair walked both ways draws as
a single straight segment between the markers (a near-vertical when the layout stacks
them), the one place an up-and-down line is the honest picture.
Two nodes on the same lane join with a level run; two on different lanes join with one smooth
**shift** — a bezier S that leaves the first marker level, drifts across the intervening lanes,
and settles level into the second (see :func:`_sbend`), so there is no corner anywhere, only
the eased level→curve→level of the shift. The shift spans the whole column gap
(:data:`_CURVE_SPAN`) rather than a centred stretch flanked by flat platforms: a platform would
meet the climbing curve at a corner, and that corner draws a heavy braille *knee*, so the curve
runs continuously node to node instead and the marker seats on the curve's own level end tangent.
The endpoints, sitting at the centre of the lane band, make the origin's diverging peels and
us's converging merges fall out of this one rule — no endpoint special case. The lone exception
is ``bidir``: a pair walked both ways draws as a single straight segment between the markers (a
near-vertical when the layout stacks them), the one place an up-and-down line is the honest
picture.
"""
(xu, yu), (xv, yv) = pos[u], pos[v]
if bidir:
@@ -534,8 +535,8 @@ def _route(
dx = xv - xu
shift = min(dx, max(_MIN_SHIFT_DOTS, round(dx * _CURVE_SPAN)))
stub = (dx - shift) // 2
# Level stub, a bezier S across the shift (level tangents at both ends, so it eases out of
# and back into the platforms with no corner), then the level stub into the far marker.
# A bezier S across the whole gap (level tangents at both ends, so it eases out of and back
# into each marker with no corner — and no platform corner to pile a heavy knee).
return [(xu, yu), *_sbend(xu + stub, yu, xv - stub, yv), (xv, yv)]