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 }); + }); });