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:
+69
-36
@@ -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).
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user