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;
|
const REGION_LABEL_SIZE = 14;
|
||||||
|
|
||||||
/** Follows glyph: screen-space distance between the two shaft CENTERLINES,
|
/** Follows glyph: screen-space distance between the two shaft CENTERLINES,
|
||||||
* in units of the edge stroke width (constant on screen — so the gap is
|
* in units of the edge stroke width — the floor that dominates at fit and
|
||||||
* too, at every zoom; DECIDED 2026-09-14, docs/vision-edge-glyph.md).
|
* zoomed-out (constant on screen, like the stroke width; DECIDED
|
||||||
* 3× a 1px stroke = 3px centers, 2px of clear space between the shafts. */
|
* 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;
|
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
|
/** 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
|
* more than this far from the scale they were built for — a handful of
|
||||||
* cheap rebuilds per zoom gesture, zero per pan/settle frame. */
|
* 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
|
/** The arrowhead geometry for a directed edge a→b: shaft endpoints
|
||||||
* (clear of both node discs) and the head triangle (tip on the target
|
* (clear of both node discs) and the head triangle (tip on the target
|
||||||
* disc edge, wideness = 0.8 × head length — the sigma arrow shape). */
|
* 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(
|
export function arrowGeometry(
|
||||||
a: Pt,
|
a: Pt,
|
||||||
b: Pt,
|
b: Pt,
|
||||||
fromRadius: number,
|
fromRadius: number,
|
||||||
toRadius: number,
|
toRadius: number,
|
||||||
headLength: number,
|
headLength: number,
|
||||||
): { shaftFrom: Pt; shaftTo: Pt; tip: Pt; left: Pt; right: Pt } | null {
|
): ArrowGeom | null {
|
||||||
const dx = b.x - a.x;
|
const dx = b.x - a.x;
|
||||||
const dy = b.y - a.y;
|
const dy = b.y - a.y;
|
||||||
const len = Math.hypot(dx, dy);
|
const len = Math.hypot(dx, dy);
|
||||||
@@ -323,11 +339,14 @@ export function arrowGeometry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The double-shaft glyph for a `follows` edge (⟹ — DECIDED
|
/** The double-shaft glyph for a `follows` edge (⟹ — DECIDED
|
||||||
* 2026-09-14, docs/vision-edge-glyph.md): two shafts offset along the
|
* 2026-09-14, docs/vision-edge-glyph.md; REVISED 2026-09-15: the
|
||||||
* edge normal by `gap` (a FIXED screen-space distance, world-converted
|
* converged variant drew two tails merging into one shared head and
|
||||||
* by the caller), converging on the shared single arrowhead — both
|
* read as two arrows feeding one point — owner rejected it). Two
|
||||||
* shafts meet at the head's base point. `supports` needs no helper
|
* COMPLETE regular arrows (own shaft + own head each), translated
|
||||||
* (the plain single shaft IS arrowGeometry). Pure: DOM-free, tested. */
|
* ±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(
|
export function followsEdgeGeometry(
|
||||||
a: Pt,
|
a: Pt,
|
||||||
b: Pt,
|
b: Pt,
|
||||||
@@ -335,7 +354,7 @@ export function followsEdgeGeometry(
|
|||||||
toRadius: number,
|
toRadius: number,
|
||||||
headLength: number,
|
headLength: number,
|
||||||
gap: 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);
|
const g = arrowGeometry(a, b, fromRadius, toRadius, headLength);
|
||||||
if (!g) return null; // degenerate (coincident) — skip
|
if (!g) return null; // degenerate (coincident) — skip
|
||||||
const dx = b.x - a.x;
|
const dx = b.x - a.x;
|
||||||
@@ -344,21 +363,27 @@ export function followsEdgeGeometry(
|
|||||||
const nx = -dy / len;
|
const nx = -dy / len;
|
||||||
const ny = dx / len;
|
const ny = dx / len;
|
||||||
const half = gap / 2;
|
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 {
|
return {
|
||||||
// Parallel tails at the source, ± half-gap along the normal. The
|
first: {
|
||||||
// offset starts sit FARTHER from the source center than the axis
|
shaftFrom: shift(g.shaftFrom, half),
|
||||||
// start (√(r²+half²) > r) — never clipped by the source disc.
|
shaftTo: shift(g.shaftTo, half),
|
||||||
shaftAFrom: { x: g.shaftFrom.x + nx * half, y: g.shaftFrom.y + ny * half },
|
tip: shift(g.tip, half),
|
||||||
shaftBFrom: { x: g.shaftFrom.x - nx * half, y: g.shaftFrom.y - ny * half },
|
left: shift(g.left, half),
|
||||||
// Convergence: both shafts end at the head's base point — the
|
right: shift(g.right, half),
|
||||||
// merge lands exactly where the shared arrowhead begins, clean at
|
},
|
||||||
// every zoom (a strictly-parallel pair would poke out beside the
|
second: {
|
||||||
// head whenever the screen-fixed gap exceeds the head's base
|
shaftFrom: shift(g.shaftFrom, -half),
|
||||||
// half-width — the head scales with the graph, the gap doesn't).
|
shaftTo: shift(g.shaftTo, -half),
|
||||||
shaftTo: g.shaftTo,
|
tip: shift(g.tip, -half),
|
||||||
tip: g.tip,
|
left: shift(g.left, -half),
|
||||||
left: g.left,
|
right: shift(g.right, -half),
|
||||||
right: g.right,
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,9 +1081,16 @@ export class GraphCanvasRenderer {
|
|||||||
const visible = this.visible;
|
const visible = this.visible;
|
||||||
const isVisible = (id: string) => !visible || visible.has(id);
|
const isVisible = (id: string) => !visible || visible.has(id);
|
||||||
const { nodeR, arrowLen } = this.world;
|
const { nodeR, arrowLen } = this.world;
|
||||||
// The follows gap in WORLD units: constant on screen (· k stays
|
// The follows gap in WORLD units. Two floors, the max of:
|
||||||
// FOLLOWS_GAP_RATIO × edgeWidth px at any zoom). Supports ignores it.
|
// - screen-anchored: FOLLOWS_GAP_RATIO × edgeWidth px on screen at
|
||||||
const gapW = (FOLLOWS_GAP_RATIO * settings.edgeWidth) / k;
|
// 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 shafts = new Path2D();
|
||||||
const heads = new Path2D();
|
const heads = new Path2D();
|
||||||
for (const e of data.edges) {
|
for (const e of data.edges) {
|
||||||
@@ -1067,17 +1099,18 @@ export class GraphCanvasRenderer {
|
|||||||
const b = this.nodeById.get(e.to);
|
const b = this.nodeById.get(e.to);
|
||||||
if (!a || !b) continue;
|
if (!a || !b) continue;
|
||||||
if (e.type === "follows") {
|
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);
|
const g = followsEdgeGeometry(a, b, nodeR, nodeR, arrowLen, gapW);
|
||||||
if (!g) continue;
|
if (!g) continue;
|
||||||
shafts.moveTo(g.shaftAFrom.x, g.shaftAFrom.y);
|
for (const arrow of [g.first, g.second]) {
|
||||||
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
|
shafts.moveTo(arrow.shaftFrom.x, arrow.shaftFrom.y);
|
||||||
shafts.moveTo(g.shaftBFrom.x, g.shaftBFrom.y);
|
shafts.lineTo(arrow.shaftTo.x, arrow.shaftTo.y);
|
||||||
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
|
heads.moveTo(arrow.tip.x, arrow.tip.y);
|
||||||
heads.moveTo(g.tip.x, g.tip.y);
|
heads.lineTo(arrow.left.x, arrow.left.y);
|
||||||
heads.lineTo(g.left.x, g.left.y);
|
heads.lineTo(arrow.right.x, arrow.right.y);
|
||||||
heads.lineTo(g.right.x, g.right.y);
|
heads.closePath();
|
||||||
heads.closePath();
|
}
|
||||||
} else {
|
} else {
|
||||||
// supports: the plain single shaft (the majority relation
|
// supports: the plain single shaft (the majority relation
|
||||||
// stays light — dashed was rejected 2026-09-14).
|
// 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`:
|
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.
|
- 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.
|
- **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)", () => {
|
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.
|
// 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)!;
|
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 4)!;
|
||||||
assert.ok(g);
|
assert.ok(g);
|
||||||
assert.ok(Math.abs(g.shaftAFrom.x - 6) < 1e-9 && Math.abs(g.shaftAFrom.y - 2) < 1e-9);
|
// Each arrow is the plain regular arrow, shifted whole: own shaft,
|
||||||
assert.ok(Math.abs(g.shaftBFrom.x - 6) < 1e-9 && Math.abs(g.shaftBFrom.y + 2) < 1e-9);
|
// own head. First at +2, second at −2 (half-gap 2).
|
||||||
// Both shafts converge on the SAME point: the head's base.
|
assert.ok(Math.abs(g.first.shaftFrom.x - 6) < 1e-9 && Math.abs(g.first.shaftFrom.y - 2) < 1e-9);
|
||||||
assert.deepEqual(g.shaftTo, { x: 89, y: 0 });
|
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)", () => {
|
test("each arrow keeps its OWN head, offset with it (no shared head)", () => {
|
||||||
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)", () => {
|
|
||||||
const a = { x: 0, y: 0 };
|
const a = { x: 0, y: 0 };
|
||||||
const b = { x: 100, y: 0 };
|
const b = { x: 100, y: 0 };
|
||||||
const plain = arrowGeometry(a, b, 6, 6, 5)!;
|
const plain = arrowGeometry(a, b, 6, 6, 5)!;
|
||||||
const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!;
|
const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!;
|
||||||
assert.deepEqual(g.tip, plain.tip);
|
// First arrow's head = plain head + (0, +2); second = −(0, +2).
|
||||||
assert.deepEqual(g.left, plain.left);
|
assert.deepEqual(g.first.tip, { x: plain.tip.x, y: plain.tip.y + 2 });
|
||||||
assert.deepEqual(g.right, plain.right);
|
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)", () => {
|
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)!;
|
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 60, y: 80 }, 10, 10, 8, 3)!;
|
||||||
assert.ok(g);
|
assert.ok(g);
|
||||||
const ax = 0.6 * 10; // axis start
|
const ax = 0.6 * 10; // axis start
|
||||||
const ay = 0.8 * 10;
|
const ay = 0.8 * 10;
|
||||||
assert.ok(Math.abs(g.shaftAFrom.x - (ax - 0.8 * 1.5)) < 1e-9);
|
assert.ok(Math.abs(g.first.shaftFrom.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.first.shaftFrom.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.second.shaftFrom.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.second.shaftFrom.y - (ay - 0.6 * 1.5)) < 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("null for coincident endpoints (same guard as the plain arrow)", () => {
|
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);
|
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", () => {
|
test("zero gap degenerates to the plain single arrow twice", () => {
|
||||||
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 0)!;
|
const a = { x: 0, y: 0 };
|
||||||
assert.deepEqual(g.shaftAFrom, g.shaftBFrom);
|
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