feat(canvas): replace snap-push with continuous soft repulsion
Shapes that overlap now drift apart gently over ~1 second via an ambient requestAnimationFrame loop, instead of snapping instantly when dragged. folk-slide and folk-arrow are exempt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
47b1642156
commit
e7c285c752
|
|
@ -291,6 +291,7 @@ export class FolkShape extends FolkElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
static GAP = 8; // minimum gap between shapes
|
static GAP = 8; // minimum gap between shapes
|
||||||
|
static pushExemptTags = new Set(["folk-arrow", "folk-slide"]);
|
||||||
|
|
||||||
#highlighted = false;
|
#highlighted = false;
|
||||||
get highlighted() {
|
get highlighted() {
|
||||||
|
|
@ -521,7 +522,6 @@ export class FolkShape extends FolkElement {
|
||||||
// Apply movement
|
// Apply movement
|
||||||
this.#rect.x += moveDelta.x;
|
this.#rect.x += moveDelta.x;
|
||||||
this.#rect.y += moveDelta.y;
|
this.#rect.y += moveDelta.y;
|
||||||
this.#resolveOverlaps(moveDelta.x, moveDelta.y);
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
this.#dispatchTransformEvent();
|
this.#dispatchTransformEvent();
|
||||||
}
|
}
|
||||||
|
|
@ -628,7 +628,6 @@ export class FolkShape extends FolkElement {
|
||||||
} else {
|
} else {
|
||||||
this.x += moveDelta.x;
|
this.x += moveDelta.x;
|
||||||
this.y += moveDelta.y;
|
this.y += moveDelta.y;
|
||||||
this.#resolveOverlaps(moveDelta.x, moveDelta.y);
|
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
|
|
@ -795,73 +794,6 @@ export class FolkShape extends FolkElement {
|
||||||
this.#dispatchTransformEvent();
|
this.#dispatchTransformEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* After moving, push overlapping siblings out of the way.
|
|
||||||
* The dragged shape stays where the cursor placed it;
|
|
||||||
* siblings are displaced in the drag direction.
|
|
||||||
*/
|
|
||||||
#resolveOverlaps(dx: number, dy: number) {
|
|
||||||
const parent = this.parentElement;
|
|
||||||
if (!parent) return;
|
|
||||||
this.#pushSiblings(parent, dx, dy, new Set<FolkShape>([this]), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For each sibling overlapping `pusher`, move the sibling out of the way.
|
|
||||||
* Recurses so chain-pushing works (A pushes B into C → C also moves).
|
|
||||||
*/
|
|
||||||
#pushSiblings(parent: Element, dx: number, dy: number, excluded: Set<FolkShape>, depth: number) {
|
|
||||||
if (depth <= 0) return;
|
|
||||||
|
|
||||||
const gap = FolkShape.GAP;
|
|
||||||
const me = { x: this.x, y: this.y, w: this.width, h: this.height };
|
|
||||||
|
|
||||||
for (const sibling of parent.children) {
|
|
||||||
if (!(sibling instanceof FolkShape)) continue;
|
|
||||||
if (excluded.has(sibling)) continue;
|
|
||||||
if (sibling.tagName.toLowerCase() === "folk-arrow") continue;
|
|
||||||
|
|
||||||
const other = { x: sibling.x, y: sibling.y, w: sibling.width, h: sibling.height };
|
|
||||||
|
|
||||||
// Check overlap (with gap buffer)
|
|
||||||
const overlapX = me.x < other.x + other.w + gap && me.x + me.w + gap > other.x;
|
|
||||||
const overlapY = me.y < other.y + other.h + gap && me.y + me.h + gap > other.y;
|
|
||||||
if (!overlapX || !overlapY) continue;
|
|
||||||
|
|
||||||
// How far to push the sibling in each direction to clear the overlap
|
|
||||||
const pushRight = (me.x + me.w + gap) - other.x; // sibling moves +x
|
|
||||||
const pushLeft = (me.x) - (other.x + other.w + gap); // sibling moves -x
|
|
||||||
const pushDown = (me.y + me.h + gap) - other.y; // sibling moves +y
|
|
||||||
const pushUp = (me.y) - (other.y + other.h + gap); // sibling moves -y
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
{ axis: "x" as const, d: pushRight },
|
|
||||||
{ axis: "x" as const, d: pushLeft },
|
|
||||||
{ axis: "y" as const, d: pushDown },
|
|
||||||
{ axis: "y" as const, d: pushUp },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Prefer directions aligned with drag movement, break ties by smallest displacement
|
|
||||||
const best = candidates.reduce((a, b) => {
|
|
||||||
const aAligned = (a.axis === "x" ? a.d * dx : a.d * dy) > 0;
|
|
||||||
const bAligned = (b.axis === "x" ? b.d * dx : b.d * dy) > 0;
|
|
||||||
if (aAligned !== bAligned) return aAligned ? a : b;
|
|
||||||
return Math.abs(a.d) < Math.abs(b.d) ? a : b;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Push the sibling (public setters trigger requestUpdate + transform events)
|
|
||||||
if (best.axis === "x") {
|
|
||||||
sibling.x += best.d;
|
|
||||||
} else {
|
|
||||||
sibling.y += best.d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse: the pushed sibling may now overlap others
|
|
||||||
const nextExcluded = new Set(excluded);
|
|
||||||
nextExcluded.add(sibling);
|
|
||||||
sibling.#pushSiblings(parent, dx, dy, nextExcluded, depth - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize shape to JSON for Automerge sync
|
* Serialize shape to JSON for Automerge sync
|
||||||
|
|
|
||||||
|
|
@ -5632,6 +5632,54 @@
|
||||||
|
|
||||||
sync.connect(wsUrl);
|
sync.connect(wsUrl);
|
||||||
|
|
||||||
|
// --- Ambient overlap repulsion ---
|
||||||
|
// Shapes that overlap slowly drift apart each frame.
|
||||||
|
const REPEL_GAP = FolkShape.GAP; // 8px desired gap
|
||||||
|
const REPEL_STRENGTH = 0.08; // resolve 8% of overlap per frame
|
||||||
|
const REPEL_THRESHOLD = 0.5; // ignore sub-pixel overlaps
|
||||||
|
|
||||||
|
function repulsionLoop() {
|
||||||
|
const shapes = [];
|
||||||
|
for (const el of canvasContent.children) {
|
||||||
|
if (!(el instanceof FolkShape)) continue;
|
||||||
|
if (FolkShape.pushExemptTags.has(el.tagName.toLowerCase())) continue;
|
||||||
|
shapes.push(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < shapes.length; i++) {
|
||||||
|
const a = shapes[i];
|
||||||
|
for (let j = i + 1; j < shapes.length; j++) {
|
||||||
|
const b = shapes[j];
|
||||||
|
|
||||||
|
// AABB overlap check (with gap)
|
||||||
|
const ox = Math.min(a.x + a.width + REPEL_GAP, b.x + b.width + REPEL_GAP) -
|
||||||
|
Math.max(a.x, b.x);
|
||||||
|
const oy = Math.min(a.y + a.height + REPEL_GAP, b.y + b.height + REPEL_GAP) -
|
||||||
|
Math.max(a.y, b.y);
|
||||||
|
if (ox <= REPEL_THRESHOLD || oy <= REPEL_THRESHOLD) continue;
|
||||||
|
|
||||||
|
// Resolve along axis of least overlap
|
||||||
|
const push = (ox < oy ? ox : oy) * REPEL_STRENGTH;
|
||||||
|
if (push < REPEL_THRESHOLD) continue;
|
||||||
|
|
||||||
|
const half = push / 2;
|
||||||
|
if (ox < oy) {
|
||||||
|
// Push apart horizontally
|
||||||
|
const sign = (a.x + a.width / 2) < (b.x + b.width / 2) ? -1 : 1;
|
||||||
|
a.x += sign * half;
|
||||||
|
b.x -= sign * half;
|
||||||
|
} else {
|
||||||
|
// Push apart vertically
|
||||||
|
const sign = (a.y + a.height / 2) < (b.y + b.height / 2) ? -1 : 1;
|
||||||
|
a.y += sign * half;
|
||||||
|
b.y -= sign * half;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(repulsionLoop);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(repulsionLoop);
|
||||||
|
|
||||||
// Debug: expose sync for console inspection
|
// Debug: expose sync for console inspection
|
||||||
window.sync = sync;
|
window.sync = sync;
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue