-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsnapAlignment.js
More file actions
182 lines (159 loc) · 4.46 KB
/
Copy pathsnapAlignment.js
File metadata and controls
182 lines (159 loc) · 4.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import { getElementBounds } from './geometry';
/** World-space snapping uses this many “CSS pixels worth” so it feels steady when zoom changes. */
export const SNAP_PEER_SCREEN_DEFAULT = 10;
/** @typedef {{ minX: number; maxX: number; minY: number; maxY: number }} AABB */
const EPS = 1e-6;
export function boundsToAABB(b) {
return {
minX: b.x,
minY: b.y,
maxX: b.x + b.width,
maxY: b.y + b.height,
};
}
export function shiftAABB(bb, dx, dy) {
return {
minX: bb.minX + dx,
maxX: bb.maxX + dx,
minY: bb.minY + dy,
maxY: bb.maxY + dy,
};
}
/**
* Bounding box of moved selection at drag start (union of bounds in world coords).
* @param {object[]} elements
* @param {string[]} movingIds
* @returns {AABB|null}
*/
export function selectionAABAtStart(elements, movingIds) {
const idSet = new Set(movingIds);
const movers = elements.filter((e) => idSet.has(e.id));
if (movers.length === 0) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
movers.forEach((el) => {
const r = boundsToAABB(getElementBounds(el));
minX = Math.min(minX, r.minX);
minY = Math.min(minY, r.minY);
maxX = Math.max(maxX, r.maxX);
maxY = Math.max(maxY, r.maxY);
});
return { minX, maxX, minY, maxY };
}
/** AABBs of every element except those being dragged. */
export function collectPeersAABBs(elements, movingIds) {
const idSet = new Set(movingIds);
return elements
.filter((e) => !idSet.has(e.id))
.map((e) => boundsToAABB(getElementBounds(e)));
}
function xAnchors(bb) {
const cx = (bb.minX + bb.maxX) / 2;
return [
{ key: 'l', pos: bb.minX },
{ key: 'c', pos: cx },
{ key: 'r', pos: bb.maxX },
];
}
function yAnchors(bb) {
const cy = (bb.minY + bb.maxY) / 2;
return [
{ key: 't', pos: bb.minY },
{ key: 'c', pos: cy },
{ key: 'b', pos: bb.maxY },
];
}
function collectXTargets(peers) {
const xs = [];
peers.forEach((b) => {
xs.push(b.minX, (b.minX + b.maxX) / 2, b.maxX);
});
return xs;
}
function collectYTargets(peers) {
const ys = [];
peers.forEach((b) => {
ys.push(b.minY, (b.minY + b.maxY) / 2, b.maxY);
});
return ys;
}
/**
* Finds smallest |correction| so an anchor aligns to a neighbor line within threshold.
* @returns {{ corr: number, target: number | null }}
*/
function best1DAnchorsToTargets(anchors, targets, threshold) {
let bestCorr = 0;
let bestMag = threshold + 1;
let bestTarget = null;
anchors.forEach((a) => {
targets.forEach((t) => {
const corr = t - a.pos;
const mag = Math.abs(corr);
if (mag <= threshold && mag + EPS < bestMag) {
bestMag = mag;
bestCorr = corr;
bestTarget = t;
}
});
});
if (bestMag > threshold) return { corr: 0, target: null };
return { corr: bestCorr, target: bestTarget };
}
/** World-space guides for overlays (converted to screen in the UI). */
export function computePeerSnap(dx, dy, bbox0, peerAABBs, thresholdWorld) {
if (
!bbox0 ||
peerAABBs.length === 0 ||
!Number.isFinite(thresholdWorld) ||
thresholdWorld <= 0
) {
return {
dx,
dy,
peerSnapActive: false,
peerSnapGuides: [],
};
}
const rawTentative = shiftAABB(bbox0, dx, dy);
const tx = collectXTargets(peerAABBs);
const ty = collectYTargets(peerAABBs);
const xAnch = xAnchors(rawTentative);
const xSnap = best1DAnchorsToTargets(xAnch, tx, thresholdWorld);
const dxAdj = dx + xSnap.corr;
const tentativeAfterX = shiftAABB(bbox0, dxAdj, dy);
const ySnap = best1DAnchorsToTargets(yAnchors(tentativeAfterX), ty, thresholdWorld);
const dyAdj = dy + ySnap.corr;
const tentativeFinal = shiftAABB(bbox0, dxAdj, dyAdj);
/** @type {Array<{ kind:'v'; x:number; y0:number; y1:number } | { kind:'h'; y:number; x0:number; x1:number }>} */
const guides = [];
let peerSnapActive = false;
const pad = 360;
if (xSnap.corr !== 0 && xSnap.target != null) {
peerSnapActive = true;
const yMid = (tentativeFinal.minY + tentativeFinal.maxY) / 2;
guides.push({
kind: 'v',
x: xSnap.target,
y0: yMid - pad,
y1: yMid + pad,
});
}
if (ySnap.corr !== 0 && ySnap.target != null) {
peerSnapActive = true;
const xMid = (tentativeFinal.minX + tentativeFinal.maxX) / 2;
guides.push({
kind: 'h',
y: ySnap.target,
x0: xMid - pad,
x1: xMid + pad,
});
}
return {
dx: dxAdj,
dy: dyAdj,
peerSnapActive,
peerSnapGuides: guides,
};
}