fix(canvas): rotate pin overlay hotspots with the component (#205)

Reporter on GitHub: after rotating a component the WIRES followed
the pin tips (already fixed in the (6,6) offset commit) but the
clickable connection boxes stayed in the unrotated layout —
visible misalignment between the rotated component and its
hotspots, no way to start a fresh wire from a rotated pin.

Root cause: PinOverlay renders as a SIBLING of the DynamicComponent
wrapper, not as a child. CSS rotation on the wrapper doesn't reach
the overlay div, so its child pin boxes stay at the unrotated
(pin.x, pin.y) coordinates.

Fix:

- Plumb component.properties.rotation from SimulatorCanvas into
  PinOverlay as a new `rotation` prop.
- In PinOverlay, capture wrapper.offsetWidth/Height when reading
  pinInfo and apply the same rotation matrix the wire calculator
  uses (pivot at wrapper center, transform-origin: center center).
- Use the rotated (pinX, pinY) for both the visual `left/top` AND
  the canvas-coord passed to onPinClick, so wires that get started
  from the hotspot anchor at the rotated tip too.

Also align the default wrapperOffsetX from 4 to 6 (padding:4 +
border:2 on each side of the DynamicComponent wrapper). The
previous asymmetric (4, 6) was the same 2px X bias we fixed in
pinPositionCalculator a few commits back; the overlay was reading
its own copy of the bad number and putting hotspots 2 px left of
the pin tip on unrotated components too. Board paths that pass
wrapperOffsetX/Y = 0 explicitly are unaffected.

All 29 vitest tests in the rotation + simulator suites pass.
This commit is contained in:
David Montero 2026-05-26 19:47:32 +02:00
parent d347cac51e
commit 0a69c9facf
2 changed files with 55 additions and 5 deletions

View File

@ -37,11 +37,19 @@ interface PinOverlayProps {
componentY: number;
onPinClick: (componentId: string, pinName: string, x: number, y: number) => void;
showPins: boolean;
/** Extra offset to compensate for wrapper padding/border. Default: 4 (x), 6 (y) for component wrappers. Pass 0 when the element has no wrapper. */
/** Extra offset to compensate for wrapper padding (4) + border (2) = 6 on each side. Default 6/6 for component wrappers. Pass 0 when the element has no wrapper (e.g. boards rendered without DynamicComponent). */
wrapperOffsetX?: number;
wrapperOffsetY?: number;
/** Current canvas zoom level — used to keep touch targets usable at any zoom */
zoom?: number;
/**
* CSS rotation (degrees) applied to the underlying DynamicComponent
* wrapper. The overlay div lives OUTSIDE that wrapper so it doesn't
* inherit the transform without this prop we rotate the pin
* coordinates manually around the wrapper's centre so the clickable
* boxes follow the visually-rotated pin tips.
*/
rotation?: number;
}
export const PinOverlay: React.FC<PinOverlayProps> = ({
@ -50,11 +58,13 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
componentY,
onPinClick,
showPins,
wrapperOffsetX = 4,
wrapperOffsetX = 6,
wrapperOffsetY = 6,
zoom = 1,
rotation = 0,
}) => {
const [pins, setPins] = useState<PinInfo[]>([]);
const [wrapperBox, setWrapperBox] = useState<{ w: number; h: number } | null>(null);
const isCoarse = useIsCoarsePointer();
useEffect(() => {
@ -62,6 +72,14 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
const element = document.getElementById(componentId);
if (element && (element as any).pinInfo) {
setPins((element as any).pinInfo);
// Capture the wrapper's unrotated bounding box for the rotation
// pivot. offsetWidth/Height stay constant regardless of CSS
// transforms, so they reflect the LAYOUT box — exactly what
// CSS rotates around with transform-origin: center center.
const wrapper = element.closest('.dynamic-component-wrapper') as HTMLElement | null;
if (wrapper) {
setWrapperBox({ w: wrapper.offsetWidth, h: wrapper.offsetHeight });
}
return true;
}
return false;
@ -71,7 +89,7 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
const t = setTimeout(tryRead, 50);
return () => clearTimeout(t);
}
}, [componentId]);
}, [componentId, rotation]);
if (!showPins || pins.length === 0) {
return null;
@ -96,8 +114,39 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
}}
>
{pins.map((pin, index) => {
const pinX = pin.x;
const pinY = pin.y;
// Container origin in CANVAS space is (componentX + wrapperOffsetX,
// componentY + wrapperOffsetY). The wrapper top-left in canvas
// space is (componentX - (6 - wrapperOffsetX), componentY - (6 -
// wrapperOffsetY)) for the DynamicComponent path, but more
// simply: padding+border on the wrapper is 6px on each side, so
// the wrapper-top-left equals containerOrigin - (6 - offset).
// Pivot for CSS rotation (transform-origin: center center) is
// wrapperTopLeft + (wrapperW/2, wrapperH/2). Express it in
// container-local coords by subtracting containerOrigin.
let pinX = pin.x;
let pinY = pin.y;
const angle = ((rotation % 360) + 360) % 360;
if (angle !== 0 && wrapperBox) {
// Wrapper interior padding+border is 6 px on each side
// (DynamicComponent: padding:4 + border:2). For boards rendered
// directly the caller passes wrapperOffsetX/Y = 0, so the
// 6 - offset arithmetic still resolves correctly (6 - 0 = 6
// would be wrong for boards which don't have a wrapper at
// all — but boards also pass rotation = 0 so we never enter
// this branch for them).
const wrapperInner = 6;
const wrapperLeftLocal = -(wrapperInner - wrapperOffsetX);
const wrapperTopLocal = -(wrapperInner - wrapperOffsetY);
const pivotX = wrapperLeftLocal + wrapperBox.w / 2;
const pivotY = wrapperTopLocal + wrapperBox.h / 2;
const theta = (angle * Math.PI) / 180;
const cos = Math.cos(theta);
const sin = Math.sin(theta);
const dx = pin.x - pivotX;
const dy = pin.y - pivotY;
pinX = pivotX + dx * cos - dy * sin;
pinY = pivotY + dx * sin + dy * cos;
}
return (
<div

View File

@ -1976,6 +1976,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
onPinClick={handlePinClick}
showPins={showPinsForComponent}
zoom={zoom}
rotation={Number(component.properties?.rotation) || 0}
/>
)}
</div>