diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index dc70f5a..068fc88 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -291,6 +291,7 @@ export class FolkShape extends FolkElement { } static GAP = 8; // minimum gap between shapes + static pushExemptTags = new Set(["folk-arrow", "folk-slide"]); #highlighted = false; get highlighted() { @@ -521,7 +522,6 @@ export class FolkShape extends FolkElement { // Apply movement this.#rect.x += moveDelta.x; this.#rect.y += moveDelta.y; - this.#resolveOverlaps(moveDelta.x, moveDelta.y); this.requestUpdate(); this.#dispatchTransformEvent(); } @@ -628,7 +628,6 @@ export class FolkShape extends FolkElement { } else { this.x += moveDelta.x; this.y += moveDelta.y; - this.#resolveOverlaps(moveDelta.x, moveDelta.y); } event.preventDefault(); return; @@ -795,73 +794,6 @@ export class FolkShape extends FolkElement { 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([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, 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 diff --git a/website/canvas.html b/website/canvas.html index f4a3b5b..85a1284 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -5632,6 +5632,54 @@ 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 window.sync = sync;