From 0a69c9facfb62ec2bbaad2099ac66e7333932fa8 Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 26 May 2026 19:47:32 +0200 Subject: [PATCH] fix(canvas): rotate pin overlay hotspots with the component (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/simulator/PinOverlay.tsx | 59 +++++++++++++++++-- .../components/simulator/SimulatorCanvas.tsx | 1 + 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/simulator/PinOverlay.tsx b/frontend/src/components/simulator/PinOverlay.tsx index 32640235..1a2b4dc0 100644 --- a/frontend/src/components/simulator/PinOverlay.tsx +++ b/frontend/src/components/simulator/PinOverlay.tsx @@ -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 = ({ @@ -50,11 +58,13 @@ export const PinOverlay: React.FC = ({ componentY, onPinClick, showPins, - wrapperOffsetX = 4, + wrapperOffsetX = 6, wrapperOffsetY = 6, zoom = 1, + rotation = 0, }) => { const [pins, setPins] = useState([]); + const [wrapperBox, setWrapperBox] = useState<{ w: number; h: number } | null>(null); const isCoarse = useIsCoarsePointer(); useEffect(() => { @@ -62,6 +72,14 @@ export const PinOverlay: React.FC = ({ 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 = ({ 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 = ({ }} > {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 (
{ onPinClick={handlePinClick} showPins={showPinsForComponent} zoom={zoom} + rotation={Number(component.properties?.rotation) || 0} /> )}