follows glyph: two complete offset arrows — no convergence, no head merge

Owner rejected the first cut: two tails converging on one shared head
reads as two arrows feeding a single point, not as parallel strokes.

- followsEdgeGeometry now returns two COMPLETE regular arrows (own shaft
  + own head each), translated ±half-gap along the edge normal —
  parallel shafts AND parallel heads, nothing converges. Offset shafts
  never clip the source disc; offset tips land a hair outside the target
  disc edge (accepted price of parallel heads).
- Gap floors: screen-anchored 3x edgeWidth (dominates at fit/zoomed-out,
  unchanged look) OR head-anchored 1.2x arrowLen (world) — without the
  head floor, the world-scaled heads merge into one blob at deep zoom
  (measured: one 10px band at 5x). max() keeps >= 0.4 arrowLen of clear
  space between the heads past the ~1.5x crossover.
- Vision doc revised (2026-09-15) to record the rejection.

Live-verified at 5x fit zoom: two shafts (11.5px gap = widened floor),
two separate ~8px-base heads with clear space between. At fit: 3px gap,
two heads, unchanged. Gates: tsc, 435/435 tests, lint, build.
This commit is contained in:
amosblomqvist
2026-09-15 08:20:04 +03:00
parent e9348b7baf
commit e8fa9403ca
3 changed files with 106 additions and 62 deletions
+69 -36
View File
@@ -172,10 +172,17 @@ const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
const REGION_LABEL_SIZE = 14;
/** Follows glyph: screen-space distance between the two shaft CENTERLINES,
* in units of the edge stroke width (constant on screen — so the gap is
* too, at every zoom; DECIDED 2026-09-14, docs/vision-edge-glyph.md).
* 3× a 1px stroke = 3px centers, 2px of clear space between the shafts. */
* in units of the edge stroke width — the floor that dominates at fit and
* zoomed-out (constant on screen, like the stroke width; DECIDED
* 2026-09-14, docs/vision-edge-glyph.md). 3× a 1px stroke = 3px centers,
* 2px of clear space between the shafts. */
const FOLLOWS_GAP_RATIO = 3;
/** Follows glyph: head-anchored gap floor, in units of the arrowhead
* length (world). Two complete arrows offset by only the screen gap
* would merge their world-scaled heads into one blob at deep zoom
* (REVISED 2026-09-15); this floor keeps the heads separated once the
* user zooms in past the crossover (~1.5× fit). */
const FOLLOWS_HEAD_CLEARANCE = 1.2;
/** Rebuild the (zoom-anchored) edge paths when the camera scale drifts
* more than this far from the scale they were built for — a handful of
* cheap rebuilds per zoom gesture, zero per pan/settle frame. */
@@ -297,13 +304,22 @@ export function fitTransform(
/** The arrowhead geometry for a directed edge a→b: shaft endpoints
* (clear of both node discs) and the head triangle (tip on the target
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */
/** One complete arrow glyph: shaft + head triangle. */
export interface ArrowGeom {
shaftFrom: Pt;
shaftTo: Pt;
tip: Pt;
left: Pt;
right: Pt;
}
export function arrowGeometry(
a: Pt,
b: Pt,
fromRadius: number,
toRadius: number,
headLength: number,
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
): ArrowGeom | null {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
@@ -323,11 +339,14 @@ export function arrowGeometry(
}
/** The double-shaft glyph for a `follows` edge (⟹ — DECIDED
* 2026-09-14, docs/vision-edge-glyph.md): two shafts offset along the
* edge normal by `gap` (a FIXED screen-space distance, world-converted
* by the caller), converging on the shared single arrowhead — both
* shafts meet at the head's base point. `supports` needs no helper
* (the plain single shaft IS arrowGeometry). Pure: DOM-free, tested. */
* 2026-09-14, docs/vision-edge-glyph.md; REVISED 2026-09-15: the
* converged variant drew two tails merging into one shared head and
* read as two arrows feeding one point — owner rejected it). Two
* COMPLETE regular arrows (own shaft + own head each), translated
* ±half-gap along the edge normal: parallel shafts, parallel heads,
* nothing converges. The gap is a FIXED screen-space distance,
* world-converted by the caller. `supports` needs no helper (the plain
* single arrow IS arrowGeometry). Pure: DOM-free, tested. */
export function followsEdgeGeometry(
a: Pt,
b: Pt,
@@ -335,7 +354,7 @@ export function followsEdgeGeometry(
toRadius: number,
headLength: number,
gap: number,
): { shaftAFrom: Pt; shaftBFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
): { first: ArrowGeom; second: ArrowGeom } | null {
const g = arrowGeometry(a, b, fromRadius, toRadius, headLength);
if (!g) return null; // degenerate (coincident) — skip
const dx = b.x - a.x;
@@ -344,21 +363,27 @@ export function followsEdgeGeometry(
const nx = -dy / len;
const ny = dx / len;
const half = gap / 2;
// Translate the whole regular arrow ±half-gap along the normal.
// Offset shafts never clip the source disc (their points sit at
// distance √(axial²+half²) > fromRadius from the center), and the
// offset tips land √(toRadius²+half²) from the target center — a
// hair outside its disc edge, the price of parallel heads (accepted).
const shift = (p: Pt, s: number): Pt => ({ x: p.x + nx * s, y: p.y + ny * s });
return {
// Parallel tails at the source, ± half-gap along the normal. The
// offset starts sit FARTHER from the source center than the axis
// start (√(r²+half²) > r) — never clipped by the source disc.
shaftAFrom: { x: g.shaftFrom.x + nx * half, y: g.shaftFrom.y + ny * half },
shaftBFrom: { x: g.shaftFrom.x - nx * half, y: g.shaftFrom.y - ny * half },
// Convergence: both shafts end at the head's base point — the
// merge lands exactly where the shared arrowhead begins, clean at
// every zoom (a strictly-parallel pair would poke out beside the
// head whenever the screen-fixed gap exceeds the head's base
// half-width — the head scales with the graph, the gap doesn't).
shaftTo: g.shaftTo,
tip: g.tip,
left: g.left,
right: g.right,
first: {
shaftFrom: shift(g.shaftFrom, half),
shaftTo: shift(g.shaftTo, half),
tip: shift(g.tip, half),
left: shift(g.left, half),
right: shift(g.right, half),
},
second: {
shaftFrom: shift(g.shaftFrom, -half),
shaftTo: shift(g.shaftTo, -half),
tip: shift(g.tip, -half),
left: shift(g.left, -half),
right: shift(g.right, -half),
},
};
}
@@ -1056,9 +1081,16 @@ export class GraphCanvasRenderer {
const visible = this.visible;
const isVisible = (id: string) => !visible || visible.has(id);
const { nodeR, arrowLen } = this.world;
// The follows gap in WORLD units: constant on screen (· k stays
// FOLLOWS_GAP_RATIO × edgeWidth px at any zoom). Supports ignores it.
const gapW = (FOLLOWS_GAP_RATIO * settings.edgeWidth) / k;
// The follows gap in WORLD units. Two floors, the max of:
// - screen-anchored: FOLLOWS_GAP_RATIO × edgeWidth px on screen at
// any zoom (dominates zoomed-out/at-fit — the shafts stay
// distinguishable even when the heads shrink to specks);
// - head-anchored: 1.2 × arrowLen (world) — the heads grow with the
// graph, and a screen-fixed gap would let them merge into one
// blob at deep zoom. This floor keeps ≥ 0.4·arrowLen of clear
// space between the two heads once zoomed in past the crossover.
// Supports ignores the gap entirely.
const gapW = Math.max((FOLLOWS_GAP_RATIO * settings.edgeWidth) / k, FOLLOWS_HEAD_CLEARANCE * arrowLen);
const shafts = new Path2D();
const heads = new Path2D();
for (const e of data.edges) {
@@ -1067,17 +1099,18 @@ export class GraphCanvasRenderer {
const b = this.nodeById.get(e.to);
if (!a || !b) continue;
if (e.type === "follows") {
// ⟹: two parallel tails, one shared head.
// ⟹: two complete regular arrows offset ±half-gap along
// the edge normal — parallel shafts AND parallel heads.
const g = followsEdgeGeometry(a, b, nodeR, nodeR, arrowLen, gapW);
if (!g) continue;
shafts.moveTo(g.shaftAFrom.x, g.shaftAFrom.y);
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
shafts.moveTo(g.shaftBFrom.x, g.shaftBFrom.y);
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
heads.moveTo(g.tip.x, g.tip.y);
heads.lineTo(g.left.x, g.left.y);
heads.lineTo(g.right.x, g.right.y);
heads.closePath();
for (const arrow of [g.first, g.second]) {
shafts.moveTo(arrow.shaftFrom.x, arrow.shaftFrom.y);
shafts.lineTo(arrow.shaftTo.x, arrow.shaftTo.y);
heads.moveTo(arrow.tip.x, arrow.tip.y);
heads.lineTo(arrow.left.x, arrow.left.y);
heads.lineTo(arrow.right.x, arrow.right.y);
heads.closePath();
}
} else {
// supports: the plain single shaft (the majority relation
// stays light — dashed was rejected 2026-09-14).
+2 -1
View File
@@ -21,7 +21,8 @@ Dash patterns break under our **constant screen-space stroke width**: the dash a
The renderer is the custom 2D canvas; edges draw in the edge layer with shafts + arrowheads from `arrowGeometry` (sigma-style head, prerequisite → dependent). For `follows`:
- Both shafts offset along the edge normal by a **fixed screen-space gap**, converging on a **shared single arrowhead**.
- Both shafts offset along the edge normal by a **fixed screen-space gap**.
- **REVISED 2026-09-15 (owner rejected the first cut):** the converged variant — two tails merging into one shared arrowhead — read as two arrows feeding a single point, not as parallel strokes. The glyph is two **complete regular arrows** (own shaft + own head each) translated ±half-gap along the normal: parallel shafts AND parallel heads, nothing converges.
- The gap is proportional to the constant stroke width — a constant visual ratio at every zoom level.
- **No LOD collapse (withdrawn 2026-09-14).** The earlier idea of collapsing to a single shaft below a stroke-width threshold assumed width shrinks with zoom — it doesn't; stroke width is constant on screen, so the double shaft renders identically at every zoom level. Simpler than proposed: nothing to gate.
+35 -25
View File
@@ -115,53 +115,63 @@ describe("arrowGeometry", () => {
});
describe("followsEdgeGeometry (the ⟹ double-shaft glyph)", () => {
test("tails offset ±half-gap along the edge normal, converging on the shared head base", () => {
test("two COMPLETE arrows translated ±half-gap along the edge normal — nothing converges", () => {
// Horizontal edge a→b: normal is (0, 1) — offsets are vertical.
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 4)!;
assert.ok(g);
assert.ok(Math.abs(g.shaftAFrom.x - 6) < 1e-9 && Math.abs(g.shaftAFrom.y - 2) < 1e-9);
assert.ok(Math.abs(g.shaftBFrom.x - 6) < 1e-9 && Math.abs(g.shaftBFrom.y + 2) < 1e-9);
// Both shafts converge on the SAME point: the head's base.
assert.deepEqual(g.shaftTo, { x: 89, y: 0 });
// Each arrow is the plain regular arrow, shifted whole: own shaft,
// own head. First at +2, second at −2 (half-gap 2).
assert.ok(Math.abs(g.first.shaftFrom.x - 6) < 1e-9 && Math.abs(g.first.shaftFrom.y - 2) < 1e-9);
assert.ok(Math.abs(g.second.shaftFrom.x - 6) < 1e-9 && Math.abs(g.second.shaftFrom.y + 2) < 1e-9);
// Shafts are PARALLEL: same span, both endpoints offset equally.
assert.ok(Math.abs(g.first.shaftTo.y - 2) < 1e-9 && Math.abs(g.second.shaftTo.y + 2) < 1e-9);
assert.ok(Math.abs(g.first.shaftTo.x - g.second.shaftTo.x) < 1e-9);
});
test("the offset tails sit farther from the source center than the axis start (never clipped by the disc)", () => {
const a = { x: 0, y: 0 };
const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!;
for (const tail of [g.shaftAFrom, g.shaftBFrom]) {
assert.ok(Math.hypot(tail.x - a.x, tail.y - a.y) >= 6 - 1e-9);
}
});
test("shared head geometry matches the plain arrow (tip/base corners)", () => {
test("each arrow keeps its OWN head, offset with it (no shared head)", () => {
const a = { x: 0, y: 0 };
const b = { x: 100, y: 0 };
const plain = arrowGeometry(a, b, 6, 6, 5)!;
const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!;
assert.deepEqual(g.tip, plain.tip);
assert.deepEqual(g.left, plain.left);
assert.deepEqual(g.right, plain.right);
// First arrow's head = plain head + (0, +2); second = −(0, +2).
assert.deepEqual(g.first.tip, { x: plain.tip.x, y: plain.tip.y + 2 });
assert.deepEqual(g.second.tip, { x: plain.tip.x, y: plain.tip.y - 2 });
assert.deepEqual(g.first.left, { x: plain.left.x, y: plain.left.y + 2 });
assert.deepEqual(g.second.right, { x: plain.right.x, y: plain.right.y - 2 });
// The two heads never meet: tips stay a full gap apart.
assert.ok(Math.abs(g.first.tip.y - g.second.tip.y - 4) < 1e-9);
});
test("the offset shafts never clip the source disc (farther than the axis start)", () => {
const a = { x: 0, y: 0 };
const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!;
for (const arrow of [g.first, g.second]) {
assert.ok(Math.hypot(arrow.shaftFrom.x - a.x, arrow.shaftFrom.y - a.y) >= 6 - 1e-9);
}
});
test("works at arbitrary angles (diagonal normal normalized)", () => {
// dir (0.6, 0.8) → normal (-0.8, 0.6); half-gap 1.5.
// dir (0.6, 0.8) → normal (−0.8, 0.6); half-gap 1.5.
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 60, y: 80 }, 10, 10, 8, 3)!;
assert.ok(g);
const ax = 0.6 * 10; // axis start
const ay = 0.8 * 10;
assert.ok(Math.abs(g.shaftAFrom.x - (ax - 0.8 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.shaftAFrom.y - (ay + 0.6 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.shaftBFrom.x - (ax + 0.8 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.shaftBFrom.y - (ay - 0.6 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.first.shaftFrom.x - (ax - 0.8 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.first.shaftFrom.y - (ay + 0.6 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.second.shaftFrom.x - (ax + 0.8 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.second.shaftFrom.y - (ay - 0.6 * 1.5)) < 1e-9);
});
test("null for coincident endpoints (same guard as the plain arrow)", () => {
assert.equal(followsEdgeGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5, 4), null);
});
test("zero gap degenerates to the plain single shaft", () => {
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 0)!;
assert.deepEqual(g.shaftAFrom, g.shaftBFrom);
test("zero gap degenerates to the plain single arrow twice", () => {
const a = { x: 0, y: 0 };
const b = { x: 100, y: 0 };
const g = followsEdgeGeometry(a, b, 6, 6, 5, 0)!;
assert.deepEqual(g.first.shaftFrom, g.second.shaftFrom);
assert.deepEqual(g.first.tip, g.second.tip);
});
});