velxio/frontend/src/utils/socketSnap.ts

127 lines
4.6 KiB
TypeScript

/**
* Board-on-socket magnet — how a shield takes a board.
*
* Some components ARE sockets: the Seeed Round Display's back header takes a
* XIAO the way a breadboard takes a part. Dragging the board near the socket
* snaps it so the two pin grids coincide, exactly like the real stack; drag it
* past the tolerance and it comes free again. Same feel as the breadboard
* magnet in breadboardSnap.ts, but for BOARDS (which move through
* setBoardPosition, not the component path).
*
* The contract is rule-6a style — read from the DOM element like pinInfo, so
* private-overlay components can declare a socket without this file knowing
* they exist:
*
* get boardSocket(): { anchorPin: string; accepts: string[] }
*
* `anchorPin` names one of the component's OWN pinInfo pads. A dragged board
* whose boardKind starts with any `accepts` prefix snaps so that ITS pad of
* the same name lands on that pad. One anchor is enough: both sides lay out
* their remaining pads at the same physical pitch, which is the whole point
* of a socket.
*/
import { calculatePinPosition } from './pinPositionCalculator';
/** DynamicComponent wrapper inset: border 2 + padding 4 on every side. */
const WRAPPER_INSET = 6;
/**
* Magnet range in world px. Chunkier than the breadboard's 9: a board is a
* two-finger object and the gesture should feel like the magnet grabs it,
* not like threading a needle. Still small enough that a deliberate drag
* away releases immediately.
*/
export const SOCKET_SNAP_TOLERANCE = 18;
interface Pt {
x: number;
y: number;
}
interface ComponentLike {
id: string;
x: number;
y: number;
properties?: Record<string, unknown>;
}
/**
* Snap a dragged board's tentative position onto the nearest accepting
* socket, or null when none is in range (which is also how it lets go).
*/
export function snapBoardToSocket(
boardId: string,
boardKind: string,
tentativeX: number,
tentativeY: number,
components: ComponentLike[],
): Pt | null {
const boardEl = document.getElementById(boardId) as
| (HTMLElement & { pinInfo?: Array<{ name: string; x: number; y: number }> })
| null;
const boardPins = boardEl?.pinInfo;
if (!boardPins || boardPins.length === 0) return null;
let best: { dx: number; dy: number; dist: number } | null = null;
for (const c of components) {
const el = document.getElementById(c.id) as
| (HTMLElement & { boardSocket?: { anchorPin: string; accepts: string[] } })
| null;
const sock = el?.boardSocket;
if (!sock || !Array.isArray(sock.accepts)) continue;
if (!sock.accepts.some((p) => boardKind.startsWith(p))) continue;
// Rotated sockets are not supported (same limit as the breadboard).
if (Number(c.properties?.rotation) || 0) continue;
const anchor = boardPins.find((p) => p.name === sock.anchorPin);
if (!anchor) continue;
const target = calculatePinPosition(
c.id,
sock.anchorPin,
c.x + WRAPPER_INSET,
c.y + WRAPPER_INSET,
0,
);
if (!target) continue;
const dx = target.x - (tentativeX + anchor.x);
const dy = target.y - (tentativeY + anchor.y);
const dist = Math.hypot(dx, dy);
if (dist <= SOCKET_SNAP_TOLERANCE && (!best || dist < best.dist)) {
best = { dx, dy, dist };
}
}
return best ? { x: tentativeX + best.dx, y: tentativeY + best.dy } : null;
}
/**
* How far off the exact seat a board may sit and still count as plugged in.
* Not zero, and deliberately far below SOCKET_SNAP_TOLERANCE: a stack the
* magnet built lands exact, but one an EXAMPLE declares (or a project saved
* before a socket's art was nudged) can be a fraction of a pixel out. At the
* old half-pixel bar such a board looked seated on screen while every seat
* test said otherwise — so it got no electrical connection and its socket
* did not travel with it. A couple of pixels is invisible to the eye and
* still nowhere near the next hole.
*/
const SEATED_EPSILON = 2;
/**
* True when the board's CURRENT position IS a socket seat. This is the
* "is it plugged in?" question, asked by z-order (a seated board must paint
* above its socket, an unseated one stays below components like every other
* board — a blanket zIndex bump once hid a resistor behind an Arduino),
* by the electrical hop that makes seating mean connection, and by the drag
* rules that keep a plugged stack together.
*/
export function isBoardSeated(
boardId: string,
boardKind: string,
x: number,
y: number,
components: ComponentLike[],
): boolean {
const seat = snapBoardToSocket(boardId, boardKind, x, y, components);
return !!seat && Math.hypot(seat.x - x, seat.y - y) <= SEATED_EPSILON;
}