From 24d25966a8c538df39d9ddf7c1e92a4e42b02a2c Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 27 Jul 2026 17:36:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20iman=20de=20zocalo=20=E2=80=94?= =?UTF-8?q?=20una=20placa=20se=20posa=20sobre=20un=20shield=20que=20la=20a?= =?UTF-8?q?cepta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Como el iman de la breadboard pero para PLACAS: un componente puede declarar en su elemento (estilo regla 6a, igual que pinInfo) que lleva un zocalo: get boardSocket(): { anchorPin, accepts } y una placa cuyo boardKind case con accepts, arrastrada cerca, se posa de golpe con su pad anchorPin sobre el homonimo del zocalo. Un solo ancla basta porque ambas rejillas comparten paso — esa es la gracia de un zocalo. Arrastrarla mas alla de la tolerancia la suelta, sin estado que recordar. Enganchado en los dos caminos de arrastre de placas (raton y tactil). El overlay privado declara el zocalo sin que este repo sepa que existe. --- .../components/simulator/SimulatorCanvas.tsx | 24 +++-- frontend/src/utils/socketSnap.ts | 95 +++++++++++++++++++ 2 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 frontend/src/utils/socketSnap.ts diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 1912cffd..a660f63b 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -48,6 +48,7 @@ import { seatOnDrop, snapPositionToBreadboard, } from '../../utils/breadboardSnap'; +import { snapBoardToSocket } from '../../utils/socketSnap'; import { findWireNearPoint, findSegmentNearPoint, @@ -878,13 +879,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const touchId = touchDraggedComponentIdRef.current; if (touchId && touchId.startsWith('__board__:')) { const boardId = touchId.slice('__board__:'.length); - setBoardPosition( - { - x: world.x - touchDragOffsetRef.current.x, - y: world.y - touchDragOffsetRef.current.y, - }, - boardId, - ); + const nb = { + x: world.x - touchDragOffsetRef.current.x, + y: world.y - touchDragOffsetRef.current.y, + }; + const bk = useSimulatorStore.getState().boards.find((b) => b.id === boardId)?.boardKind; + const seated = bk && snapBoardToSocket(boardId, bk, nb.x, nb.y, componentsRef.current ?? []); + setBoardPosition(seated || nb, boardId); } else if (touchId === '__board__') { setBoardPosition({ x: world.x - touchDragOffsetRef.current.x, @@ -1515,7 +1516,14 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const world = toWorld(e.clientX, e.clientY); if (draggedComponentId.startsWith('__board__:')) { const boardId = draggedComponentId.slice('__board__:'.length); - setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y }, boardId); + { + // Socket magnet: a shield that declares boardSocket (the Round + // Display's XIAO header) grabs a matching board dragged onto it. + const nb = { x: world.x - dragOffset.x, y: world.y - dragOffset.y }; + const bk = useSimulatorStore.getState().boards.find((b) => b.id === boardId)?.boardKind; + const seated = bk && snapBoardToSocket(boardId, bk, nb.x, nb.y, componentsRef.current ?? []); + setBoardPosition(seated || nb, boardId); + } } else if (draggedComponentId === '__board__') { // legacy fallback setBoardPosition({ x: world.x - dragOffset.x, y: world.y - dragOffset.y }); diff --git a/frontend/src/utils/socketSnap.ts b/frontend/src/utils/socketSnap.ts new file mode 100644 index 00000000..fed4f47e --- /dev/null +++ b/frontend/src/utils/socketSnap.ts @@ -0,0 +1,95 @@ +/** + * 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; +} + +/** + * 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; +}