Withdraw the double-shaft edge glyph — both variants rejected live
The converged variant read as two arrows feeding one point; the two- complete-arrows variant as two arrows next to each other, not coherent. Owner withdrew the double-shaft idea entirely (2026-09-15); dashed was already rejected 2026-09-14. Both relation types draw the same plain single arrow again. Kept: CanvasEdge.type plumbing from the snapshot (the next differentiator will need it) and the ArrowGeom named type. Removed: followsEdgeGeometry, the gap constants, the zoom-drift edge-path rebuild (edges are back to a single loop in buildGeometry), the ⟹ legend entry, and the glyph tests. Vision doc records the withdrawal and keeps the still-valid constraints. Gates: tsc, 429/429 tests, lint, build. Live: canvas coverage 0.477, no console errors, no legend remnants.
This commit is contained in:
+24
-148
@@ -171,22 +171,6 @@ const COMMIT_DEBOUNCE_MS = 250; // settle → ledger PUT delay (new drags cancel
|
|||||||
const REGION_EVERY = 3; // live-sim frames: rebuild every Nth, blit the rest
|
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,
|
|
||||||
* 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. */
|
|
||||||
const EDGE_PATHS_ZOOM_EPSILON = 0.02;
|
|
||||||
|
|
||||||
|
|
||||||
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
|
/** One horizontal+vertical box-blur round over PREMULTIPLIED RGBA
|
||||||
@@ -338,54 +322,6 @@ export function arrowGeometry(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The double-shaft glyph for a `follows` edge (⟹ — DECIDED
|
|
||||||
* 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,
|
|
||||||
fromRadius: number,
|
|
||||||
toRadius: number,
|
|
||||||
headLength: number,
|
|
||||||
gap: number,
|
|
||||||
): { 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;
|
|
||||||
const dy = b.y - a.y;
|
|
||||||
const len = Math.hypot(dx, dy);
|
|
||||||
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 {
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nearest node within maxDist of a screen point (linear scan — µs at
|
/** Nearest node within maxDist of a screen point (linear scan — µs at
|
||||||
* our scale; a spatial grid only earns its keep past ~10k nodes). */
|
* our scale; a spatial grid only earns its keep past ~10k nodes). */
|
||||||
@@ -450,7 +386,6 @@ interface WorldSizes {
|
|||||||
glowOuter: number; // hover halo outer radius, world units
|
glowOuter: number; // hover halo outer radius, world units
|
||||||
labelSize: number; // node label font, world units
|
labelSize: number; // node label font, world units
|
||||||
labelGap: number; // node edge → first label line, world units
|
labelGap: number; // node edge → first label line, world units
|
||||||
arrowLen: number; // arrowhead length, world units (shared by both glyphs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pre-wrapped node name, centered below the node in WORLD units —
|
/** The pre-wrapped node name, centered below the node in WORLD units —
|
||||||
@@ -547,10 +482,6 @@ export class GraphCanvasRenderer {
|
|||||||
// changes (geoVersion). Camera moves just re-rasterize the same paths.
|
// changes (geoVersion). Camera moves just re-rasterize the same paths.
|
||||||
private geoVersion = 0; // bump = geometry inputs changed
|
private geoVersion = 0; // bump = geometry inputs changed
|
||||||
private builtVersion = -1; // version the cache was built at
|
private builtVersion = -1; // version the cache was built at
|
||||||
/** Edge paths: built for this geoVersion AND this camera scale (the
|
|
||||||
* follows gap is screen-anchored). -1/0 force the first build. */
|
|
||||||
private edgeBuiltVersion = -1;
|
|
||||||
private edgeBuiltK = 0;
|
|
||||||
private nodesPath: Path2D | null = null;
|
private nodesPath: Path2D | null = null;
|
||||||
private shaftsPath: Path2D = new Path2D();
|
private shaftsPath: Path2D = new Path2D();
|
||||||
private headsPath: Path2D = new Path2D();
|
private headsPath: Path2D = new Path2D();
|
||||||
@@ -567,7 +498,7 @@ export class GraphCanvasRenderer {
|
|||||||
private frameCount = 0; // draw counter for the live-sim splat throttle
|
private frameCount = 0; // draw counter for the live-sim splat throttle
|
||||||
private labelLines = new Map<string, string[]>();
|
private labelLines = new Map<string, string[]>();
|
||||||
private worldLabelFont = "";
|
private worldLabelFont = "";
|
||||||
private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0, arrowLen: 0 };
|
private world: WorldSizes = { nodeR: 0, glowOuter: 0, labelSize: 0, labelGap: 0 };
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) {
|
constructor(canvas: HTMLCanvasElement, callbacks: RendererCallbacks) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
@@ -1029,7 +960,6 @@ export class GraphCanvasRenderer {
|
|||||||
glowOuter: nodeR * GLOW_SCALE,
|
glowOuter: nodeR * GLOW_SCALE,
|
||||||
labelSize: settings.labelSize * s,
|
labelSize: settings.labelSize * s,
|
||||||
labelGap: LABEL_GAP * s,
|
labelGap: LABEL_GAP * s,
|
||||||
arrowLen: settings.arrowLength * s,
|
|
||||||
};
|
};
|
||||||
const visible = this.visible;
|
const visible = this.visible;
|
||||||
const isVisible = (id: string) => !visible || visible.has(id);
|
const isVisible = (id: string) => !visible || visible.has(id);
|
||||||
@@ -1043,10 +973,29 @@ export class GraphCanvasRenderer {
|
|||||||
}
|
}
|
||||||
this.nodesPath = nodesPath;
|
this.nodesPath = nodesPath;
|
||||||
|
|
||||||
// Edge shafts + heads are NOT built here: the follows glyph's gap
|
// Edge shafts + arrowheads: tip lands on the target disc edge. Both
|
||||||
// is anchored to the CURRENT camera scale (constant on screen), so
|
// relation types draw the same arrow for now — the double-shaft
|
||||||
// buildEdgePaths(k) runs from draw() on its own schedule — geometry
|
// glyph was rejected (2026-09-15); differentiation is pending a new
|
||||||
// changes AND camera-scale drift (EDGE_PATHS_ZOOM_EPSILON).
|
// approach. CanvasEdge.type is plumbed for whatever comes next.
|
||||||
|
const arrowLen = settings.arrowLength * s;
|
||||||
|
const shafts = new Path2D();
|
||||||
|
const heads = new Path2D();
|
||||||
|
for (const e of data.edges) {
|
||||||
|
if (!isVisible(e.from) || !isVisible(e.to)) continue;
|
||||||
|
const a = this.nodeById.get(e.from);
|
||||||
|
const b = this.nodeById.get(e.to);
|
||||||
|
if (!a || !b) continue;
|
||||||
|
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
|
||||||
|
if (!g) continue;
|
||||||
|
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.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();
|
||||||
|
}
|
||||||
|
this.shaftsPath = shafts;
|
||||||
|
this.headsPath = heads;
|
||||||
|
|
||||||
// Cluster regions are NOT built here: they rasterize into their own
|
// Cluster regions are NOT built here: they rasterize into their own
|
||||||
// low-res bitmap (see buildRegions), on their own rebuild schedule
|
// low-res bitmap (see buildRegions), on their own rebuild schedule
|
||||||
@@ -1066,68 +1015,6 @@ export class GraphCanvasRenderer {
|
|||||||
ctx.font = prevFont;
|
ctx.font = prevFont;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edge shafts + arrowheads (the relation glyphs). Own build schedule,
|
|
||||||
* separate from buildGeometry: the follows glyph's shaft gap is a
|
|
||||||
* FIXED screen-space distance (FOLLOWS_GAP_RATIO × stroke width —
|
|
||||||
* constant on screen at every zoom, docs/vision-edge-glyph.md), so
|
|
||||||
* in world units it scales with 1/k and must be rebuilt as the camera
|
|
||||||
* zoom drifts (EDGE_PATHS_ZOOM_EPSILON gates that — pans and settle
|
|
||||||
* frames reuse the paths). Cheap either way: ~hundreds of edges ×
|
|
||||||
* trivial trig, no rasterization. */
|
|
||||||
private buildEdgePaths(k: number): void {
|
|
||||||
const data = this.data;
|
|
||||||
const settings = this.settings;
|
|
||||||
if (!data || !settings) return;
|
|
||||||
const visible = this.visible;
|
|
||||||
const isVisible = (id: string) => !visible || visible.has(id);
|
|
||||||
const { nodeR, arrowLen } = this.world;
|
|
||||||
// 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) {
|
|
||||||
if (!isVisible(e.from) || !isVisible(e.to)) continue;
|
|
||||||
const a = this.nodeById.get(e.from);
|
|
||||||
const b = this.nodeById.get(e.to);
|
|
||||||
if (!a || !b) continue;
|
|
||||||
if (e.type === "follows") {
|
|
||||||
// ⟹: 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;
|
|
||||||
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).
|
|
||||||
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
|
|
||||||
if (!g) continue;
|
|
||||||
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.shaftsPath = shafts;
|
|
||||||
this.headsPath = heads;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** (Re)create the live sim from data.nodes at their current
|
/** (Re)create the live sim from data.nodes at their current
|
||||||
* positions + the current physics tuning. Called by setData and
|
* positions + the current physics tuning. Called by setData and
|
||||||
* setPhysicsTuning. */
|
* setPhysicsTuning. */
|
||||||
@@ -1412,17 +1299,6 @@ export class GraphCanvasRenderer {
|
|||||||
if (!this.nodesPath) return;
|
if (!this.nodesPath) return;
|
||||||
|
|
||||||
const { x: tx, y: ty, k } = this.transform;
|
const { x: tx, y: ty, k } = this.transform;
|
||||||
// Edge paths: rebuild on geometry changes OR camera-scale drift
|
|
||||||
// (the follows glyph's gap is constant on screen, so its world size
|
|
||||||
// tracks 1/k). Pans and settle frames reuse the cached paths.
|
|
||||||
if (
|
|
||||||
this.edgeBuiltVersion !== this.geoVersion ||
|
|
||||||
Math.abs(k - this.edgeBuiltK) > this.edgeBuiltK * EDGE_PATHS_ZOOM_EPSILON
|
|
||||||
) {
|
|
||||||
this.buildEdgePaths(k);
|
|
||||||
this.edgeBuiltVersion = this.geoVersion;
|
|
||||||
this.edgeBuiltK = k;
|
|
||||||
}
|
|
||||||
// THE camera: one transform, then everything is world coordinates.
|
// THE camera: one transform, then everything is world coordinates.
|
||||||
ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty);
|
ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty);
|
||||||
|
|
||||||
|
|||||||
@@ -896,19 +896,6 @@ export function GraphView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Edge glyph legend (bottom-right; the pipeline status pills own
|
|
||||||
the bottom-left). ⟹ follows (the derivation backbone) · →
|
|
||||||
supports — DECIDED 2026-09-14, docs/vision-edge-glyph.md. */}
|
|
||||||
{!isEmpty && (
|
|
||||||
<div className="pointer-events-none absolute bottom-3 right-3 z-10 flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur">
|
|
||||||
<span className="font-medium text-foreground">⟹</span>
|
|
||||||
<span>follows</span>
|
|
||||||
<span className="text-muted-foreground/60">·</span>
|
|
||||||
<span className="font-medium text-foreground">→</span>
|
|
||||||
<span>supports</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background pipeline status: what is currently running. */}
|
{/* Background pipeline status: what is currently running. */}
|
||||||
{pipelineRuns.length > 0 && (
|
{pipelineRuns.length > 0 && (
|
||||||
<div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5">
|
<div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
# Vision — The edge glyph: follows vs supports
|
# Vision — The edge glyph: follows vs supports
|
||||||
|
|
||||||
Ideation settled 2026-09-14. Not yet planned, not yet assigned. Companion: `vision.md` § "Edges" (the relation types) and § "Rendering".
|
Ideation settled 2026-09-14. **WITHDRAWN 2026-09-15**: both shipped variants of
|
||||||
|
the double-shaft glyph (tails converging on a shared head; two complete arrows
|
||||||
|
offset ±half-gap) were rejected by the owner live — the first "two arrows
|
||||||
|
feeding one point", the second "two arrows next to each other, not coherent".
|
||||||
|
Dashed was already rejected 2026-09-14 (dash patterns crawl under the constant
|
||||||
|
screen-space stroke width). **The relation still needs a differentiator —
|
||||||
|
approach open.** The constraints below that are NOT glyph-specific still stand.
|
||||||
|
Companion: `vision.md` § "Edges" (the relation types) and § "Rendering".
|
||||||
|
|
||||||
One line: the two relation types become visually distinct — **`follows` renders as a double-shaft arrow** (two parallel tails side by side, the implication glyph ⟹), **`supports` stays a single line**. Arrows keep their existing direction: prerequisite → dependent.
|
One line (withdrawn): the two relation types become visually distinct —
|
||||||
|
**`follows` renders as a double-shaft arrow** (two parallel tails side by
|
||||||
|
side, the implication glyph ⟹), **`supports` stays a single line**. Arrows
|
||||||
|
keep their existing direction: prerequisite → dependent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it as test } from "node:test";
|
import { describe, it as test } from "node:test";
|
||||||
import {
|
import {
|
||||||
arrowGeometry,
|
arrowGeometry,
|
||||||
followsEdgeGeometry,
|
|
||||||
boxBlurRound,
|
boxBlurRound,
|
||||||
fitTransform,
|
fitTransform,
|
||||||
labelAlpha,
|
labelAlpha,
|
||||||
@@ -114,66 +113,6 @@ describe("arrowGeometry", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("followsEdgeGeometry (the ⟹ double-shaft glyph)", () => {
|
|
||||||
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);
|
|
||||||
// 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("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)!;
|
|
||||||
// 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.
|
|
||||||
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.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 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pickNearest (distance-check picking)", () => {
|
describe("pickNearest (distance-check picking)", () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
|
|||||||
Reference in New Issue
Block a user