From 65dfda2342573272a78c923e6aa42f372f177279 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 15 Sep 2026 08:38:13 +0300 Subject: [PATCH] =?UTF-8?q?Withdraw=20the=20double-shaft=20edge=20glyph=20?= =?UTF-8?q?=E2=80=94=20both=20variants=20rejected=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/graph-canvas.ts | 172 ++++++------------------------------- components/graph-view.tsx | 13 --- docs/vision-edge-glyph.md | 14 ++- tests/graph-canvas.test.ts | 61 ------------- 4 files changed, 36 insertions(+), 224 deletions(-) diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 0d498aa..fd941f5 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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_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 @@ -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 * 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 labelSize: number; // node label font, 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 — @@ -547,10 +482,6 @@ export class GraphCanvasRenderer { // changes (geoVersion). Camera moves just re-rasterize the same paths. private geoVersion = 0; // bump = geometry inputs changed 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 shaftsPath: 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 labelLines = new Map(); 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) { this.canvas = canvas; @@ -1029,7 +960,6 @@ export class GraphCanvasRenderer { glowOuter: nodeR * GLOW_SCALE, labelSize: settings.labelSize * s, labelGap: LABEL_GAP * s, - arrowLen: settings.arrowLength * s, }; const visible = this.visible; const isVisible = (id: string) => !visible || visible.has(id); @@ -1043,10 +973,29 @@ export class GraphCanvasRenderer { } this.nodesPath = nodesPath; - // Edge shafts + heads are NOT built here: the follows glyph's gap - // is anchored to the CURRENT camera scale (constant on screen), so - // buildEdgePaths(k) runs from draw() on its own schedule — geometry - // changes AND camera-scale drift (EDGE_PATHS_ZOOM_EPSILON). + // Edge shafts + arrowheads: tip lands on the target disc edge. Both + // relation types draw the same arrow for now — the double-shaft + // glyph was rejected (2026-09-15); differentiation is pending a new + // 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 // low-res bitmap (see buildRegions), on their own rebuild schedule @@ -1066,68 +1015,6 @@ export class GraphCanvasRenderer { 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 * positions + the current physics tuning. Called by setData and * setPhysicsTuning. */ @@ -1412,17 +1299,6 @@ export class GraphCanvasRenderer { if (!this.nodesPath) return; 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. ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 76f96a2..ff001ba 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -896,19 +896,6 @@ export function GraphView() { )} - {/* 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 && ( -
- ⟹ - follows - · - → - supports -
- )} - {/* Background pipeline status: what is currently running. */} {pipelineRuns.length > 0 && (
diff --git a/docs/vision-edge-glyph.md b/docs/vision-edge-glyph.md index 129b58d..c79bb0e 100644 --- a/docs/vision-edge-glyph.md +++ b/docs/vision-edge-glyph.md @@ -1,8 +1,18 @@ # 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. --- diff --git a/tests/graph-canvas.test.ts b/tests/graph-canvas.test.ts index 522bdc3..ef7ebe3 100644 --- a/tests/graph-canvas.test.ts +++ b/tests/graph-canvas.test.ts @@ -13,7 +13,6 @@ import assert from "node:assert/strict"; import { describe, it as test } from "node:test"; import { arrowGeometry, - followsEdgeGeometry, boxBlurRound, fitTransform, 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)", () => { const nodes = [