Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
};
});
Expand All @@ -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;
Expand All @@ -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;
})
Expand Down
37 changes: 36 additions & 1 deletion packages/web/src/lib/__tests__/edgeGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 });
});
});
32 changes: 32 additions & 0 deletions packages/web/src/lib/edgeGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) −
Expand Down
71 changes: 71 additions & 0 deletions tests/diagnostics/graph-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
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 });
});
});
Loading