feat(canvas): iman de zocalo — una placa se posa sobre un shield que la acepta
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.
This commit is contained in:
parent
f318b06116
commit
24d25966a8
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
Loading…
Reference in New Issue