Graph: optionales Grid-Snapping beim Ziehen von Nodes

Ein Toggle in der Graph-Kopfzeile lässt gezogene Nodes auf dem 24er-Raster
einrasten – derselben Größe, die den Hintergrund zeichnet. Einschalten bewegt
nichts; die Regel greift erst beim nächsten Zug. Bei Mehrfachauswahl richtet
sich jeder Node für sich aus, statt einem gemeinsamen Versatz zu folgen, der
die alten krummen Abstände mitschleppen würde.

Die Entscheidung liegt als reine Funktion in framing.ts, wo die übrige
Graph-Geometrie schon wohnt, und ist dort wie im Browser gepinnt.
This commit is contained in:
root committed 2026-09-10 17:14:37 +02:00
1 parent 10d030376a
commit 4b47741fac
9 files changed
+187 -14

No files matched your search

+80
View File
@@ -389,6 +389,86 @@ export async function graph(t) {
await undo();
check('one undo takes the group move back', (await positions()) === before13i, await positions());
// 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30),
// so a screen delta is a graph delta.
const GRID = 24;
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
const snapLabel = await locate('Snap');
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
check(
'the toggle switches it on without moving a single node',
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
await positions(),
);
// A single node: dropped between two grid lines, it lands on the nearer one.
await click(corner.x, corner.y);
await sleep(150);
const snapPre = await nodeOf('n-axis');
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
const snapped = await nodeOf('n-axis');
check(
'a dragged node lands on the grid, not where the pointer let go',
onGrid(snapped) &&
Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 &&
Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2,
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
);
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
check(
'the nodes it was dragged past stay off the grid',
others.some((n) => !onGrid(n)),
JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)),
);
// A group: every selected node snaps for itself, so the whole selection ends up on grid lines
// – off-grid gaps inside it close, rather than travelling along.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
check(
'the selection is not on the grid yet',
groupPre.some((n) => !onGrid(n)),
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
);
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
const groupPost = (await asset('mover')).graph.nodes;
check(
'every selected node lines up on the grid for itself, none by more than half a step',
groupPost.every(onGrid) &&
groupPre.every((n) => {
const now = groupPost.find((m) => m.id === n.id);
return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2;
}),
await positions(),
);
await undo();
await undo();
check('undo takes both snapped moves back', (await positions()) === before13i, await positions());
// Switching it off has to give the pointer its exact drop position back.
await click(snapLabel.x + 30, snapLabel.y);
await sleep(150);
await click(corner.x, corner.y);
await sleep(150);
const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
const offPost = await nodeOf('n-axis');
check(
'switched off, a node lands exactly where it was dropped again',
(await json('__gm.graph.snapping()')) === false &&
offPost.x === offPre.x + 50 &&
offPost.y === offPre.y + 50,
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
);
await undo();
await sleep(150);
// 13l. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts();