From f023d7c1367a27826d00ee44448fad44716acd73 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 22:46:07 -0700 Subject: [PATCH] Drag-time clamp: a node can't be dragged closer than its edge-label minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the border-attachment / min-edge-length work. The minEdge force governs the AUTO-layout, but a user could still drag two nodes on top of each other (the dragged node is pinned to the cursor, so the force can't move it). Now the node drag handler clamps the dragged node's target so it stays at least the edge-label minimum (edge._minLen) away from any connected neighbor that isn't moving with it (cluster-co-moving free neighbors keep their distance, so they're excluded). New pure, unit-tested helper clampToMinNeighbors() projects the target out of each neighbor's min-radius circle (a few passes resolve multiple neighbors). Verified end-to-end (geometry diagnostic): dragging one node right onto a connected anchor stops at centerDist=306 = minLen=306 — the "IS PART OF" label still displays between them. web units 118 (5 new clamp tests), THE GATE 5/5, perf budgets 3/3. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 32 ++++++--- .../src/lib/__tests__/edgeGeometry.test.ts | 37 +++++++++- packages/web/src/lib/edgeGeometry.ts | 32 +++++++++ tests/diagnostics/graph-geometry.spec.ts | 71 +++++++++++++++++++ 4 files changed, 161 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 766ae0f..6879577 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -46,7 +46,7 @@ import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerg import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } from '../lib/edgeLabelLayout'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; -import { edgeBorderEndpoints, minEdgeLength } from '../lib/edgeGeometry'; +import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; import { spawnCelebration } from '../lib/celebration'; import { buildNeighborhood } from '../lib/graphAdjacency'; import { UndoStack } from '../lib/undoStack'; @@ -2227,6 +2227,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const connectedNode = edge.source.id === d.id ? edge.target : edge.source; return { node: connectedNode, + edge, // keep the edge so the drag clamp can read its _minLen wasFixed: connectedNode.fx !== null || connectedNode.fy !== null }; }); @@ -2245,12 +2246,23 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Threshold for switching from cluster movement to edge stretching const stretchThreshold = 80; // pixels - - if (dragDistance < stretchThreshold) { + const clustering = dragDistance < stretchThreshold; + + // Drag-time hard clamp: the dragged node may not get closer than the + // edge-label minimum to a connected neighbor that ISN'T moving with it + // (cluster-co-moving free neighbors keep their distance automatically, + // so they're excluded). This is the interactive twin of the minEdge + // force, which only governs the auto-layout. + const clampNeighbors = (d._connectedNodes || []) + .filter((c: any) => !(clustering && !c.wasFixed)) + .map((c: any) => ({ x: c.node.x || 0, y: c.node.y || 0, minLen: c.edge?._minLen || 0 })); + const tgt = clampToMinNeighbors({ x: event.x, y: event.y }, clampNeighbors); + + if (clustering) { // Cluster movement - move connected nodes together - const deltaX = event.x - d.x; - const deltaY = event.y - d.y; - + const deltaX = tgt.x - d.x; + const deltaY = tgt.y - d.y; + d._connectedNodes.forEach(({ node, wasFixed }: { node: any, wasFixed: boolean }) => { if (!wasFixed) { // Only move if not already fixed by user previously node.fx = (node.fx || node.x) + deltaX; @@ -2268,10 +2280,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap } }); } - - // Move the dragged node - d.fx = event.x; - d.fy = event.y; + + // Move the dragged node to the clamped target + d.fx = tgt.x; + d.fy = tgt.y; d.x = d.fx; d.y = d.fy; }) diff --git a/packages/web/src/lib/__tests__/edgeGeometry.test.ts b/packages/web/src/lib/__tests__/edgeGeometry.test.ts index d0cda9c..e080b68 100644 --- a/packages/web/src/lib/__tests__/edgeGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeGeometry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength } from '../edgeGeometry'; +import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors } from '../edgeGeometry'; describe('rectBorderPoint', () => { const dims = { width: 100, height: 60 }; // hw=50, hh=30 @@ -73,3 +73,38 @@ describe('minEdgeLength', () => { expect(gap).toBeGreaterThanOrEqual(labelW); }); }); + +describe('clampToMinNeighbors (drag-time min edge length)', () => { + it('leaves a target that is already far enough alone', () => { + const p = clampToMinNeighbors({ x: 300, y: 0 }, [{ x: 0, y: 0, minLen: 200 }]); + expect(p).toEqual({ x: 300, y: 0 }); + }); + + it('pushes a too-close target out to exactly the min radius', () => { + const p = clampToMinNeighbors({ x: 50, y: 0 }, [{ x: 0, y: 0, minLen: 200 }]); + expect(Math.hypot(p.x, p.y)).toBeCloseTo(200, 6); + expect(p.y).toBeCloseTo(0, 6); // stays on the same ray + expect(p.x).toBeCloseTo(200, 6); + }); + + it('pushes out in a stable direction when the target sits on the neighbor', () => { + const p = clampToMinNeighbors({ x: 0, y: 0 }, [{ x: 0, y: 0, minLen: 120 }]); + expect(Math.hypot(p.x, p.y)).toBeCloseTo(120, 6); + }); + + it('respects multiple neighbors (target ends up outside every min radius)', () => { + const neighbors = [ + { x: 0, y: 0, minLen: 150 }, + { x: 100, y: 0, minLen: 150 }, + ]; + const p = clampToMinNeighbors({ x: 50, y: 10 }, neighbors, 8); + for (const n of neighbors) { + expect(Math.hypot(p.x - n.x, p.y - n.y)).toBeGreaterThanOrEqual(150 - 1e-6); + } + }); + + it('ignores neighbors with no minimum', () => { + const p = clampToMinNeighbors({ x: 5, y: 5 }, [{ x: 0, y: 0, minLen: 0 }]); + expect(p).toEqual({ x: 5, y: 5 }); + }); +}); diff --git a/packages/web/src/lib/edgeGeometry.ts b/packages/web/src/lib/edgeGeometry.ts index 6fe4ada..1c9485e 100644 --- a/packages/web/src/lib/edgeGeometry.ts +++ b/packages/web/src/lib/edgeGeometry.ts @@ -57,6 +57,38 @@ export function halfDiagonal(dims: Dims): number { return Math.hypot(dims.width, dims.height) / 2; } +export interface MinNeighbor { x: number; y: number; minLen: number; } + +/** + * Clamp a dragged node's target so it never sits closer than `minLen` to any + * neighbor — drag-time enforcement of the minimum-edge-length rule. Projects + * the target out of each neighbor's min-radius circle; a few passes resolve + * multiple neighbors approximately (the cursor simply can't push past the + * nearest constraint). Pure — the caller supplies neighbor positions + mins. + */ +export function clampToMinNeighbors(target: Pt, neighbors: MinNeighbor[], iterations = 4): Pt { + let x = target.x; + let y = target.y; + for (let i = 0; i < iterations; i++) { + let moved = false; + for (const n of neighbors) { + if (!(n.minLen > 0)) continue; + let dx = x - n.x; + let dy = y - n.y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { dx = 1; dy = 0; dist = 1; } // arbitrary push-out direction + if (dist < n.minLen) { + const s = n.minLen / dist; + x = n.x + dx * s; + y = n.y + dy * s; + moved = true; + } + } + if (!moved) break; + } + return { x, y }; +} + /** * Minimum CENTER-to-CENTER distance so the edge label always fits in the * border-to-border gap, at ANY angle. The visible gap = centerLen − proj(src) − diff --git a/tests/diagnostics/graph-geometry.spec.ts b/tests/diagnostics/graph-geometry.spec.ts index 5ed44bd..d467322 100644 --- a/tests/diagnostics/graph-geometry.spec.ts +++ b/tests/diagnostics/graph-geometry.spec.ts @@ -265,4 +265,75 @@ test.describe('graph geometry diagnostic @geometry', () => { await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: flowId }); await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: flowId }); }); + + test('drag-time clamp: a node cannot be dragged closer than the label minimum', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} DragClamp ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + // Two placed nodes, far apart, joined by a wide-label edge. + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: [ + { type: 'TASK', title: 'anchor', status: 'IN_PROGRESS', priority: 0.5, positionX: -260, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } }, + { type: 'TASK', title: 'dragme', status: 'IN_PROGRESS', priority: 0.5, positionX: 260, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } }, + ] }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [{ type: 'IS_PART_OF', weight: 0.6, source: { connect: { where: { node: { id: ids.dragme } } } }, target: { connect: { where: { node: { id: ids.anchor } } } } }] }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(6000); + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(1000); + + const centerOf = (title: string) => page.evaluate((t) => { + const n = [...document.querySelectorAll('.graph-container svg .node')].find((el: any) => el.__data__?.title === t) as any; + if (!n) return null; + const r = (n.querySelector('.node-bg') as Element).getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, title); + + const anchor = await centerOf('anchor'); + const drag = await centerOf('dragme'); + expect(anchor && drag, 'both nodes on screen').toBeTruthy(); + + // Drag "dragme" right onto "anchor" (and past it) — the clamp must stop it. + await page.mouse.move(drag!.x, drag!.y); + await page.mouse.down(); + const steps = 24; + for (let i = 1; i <= steps; i++) { + await page.mouse.move(drag!.x + (anchor!.x - drag!.x) * (i / steps), drag!.y + (anchor!.y - drag!.y) * (i / steps), { steps: 1 }); + await page.waitForTimeout(15); + } + await page.mouse.up(); + await page.waitForTimeout(1500); + await page.screenshot({ path: path.join(OUT, 'drag-clamp.png') }); + + // Final graph-space center distance + the edge's enforced minimum. + const result = await page.evaluate(() => { + const node = (t: string) => [...document.querySelectorAll('.graph-container svg .node')].find((el: any) => el.__data__?.title === t) as any; + const a = node('anchor')?.__data__, b = node('dragme')?.__data__; + const edge = [...document.querySelectorAll('.graph-container svg .edge')].map((e: any) => e.__data__).find((d: any) => d && d._minLen); + return { dist: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : -1, minLen: edge?._minLen ?? -1 }; + }); + // eslint-disable-next-line no-console + console.log(`[geometry:drag] after dragging onto the anchor: centerDist=${Math.round(result.dist)} minLen=${Math.round(result.minLen)}`); + + expect(result.minLen, 'edge has a computed minimum length').toBeGreaterThan(0); + // The clamp must keep them apart — allow a small tolerance for the iterative + // projection + a tick of settling. + expect(result.dist, 'dragged node was held at the label minimum, not on top of the anchor').toBeGreaterThanOrEqual(result.minLen - 25); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); });