velxio/frontend/src/components/simulator/PinOverlay.tsx

192 lines
6.7 KiB
TypeScript

/**
* PinOverlay Component
*
* Renders clickable pin indicators over components to enable wire creation.
* Shows when hovering over a component or when creating a wire.
*
* On touch devices the hit-target is scaled up inversely to the canvas zoom
* so the *screen-space* tap area stays at least ~40px regardless of zoom level.
*/
import React, { useEffect, useState } from 'react';
import { useIsCoarsePointer } from '../../utils/useTouchDevice';
/** Minimum visual pin size in *world* pixels at zoom 1 */
const PIN_VISUAL = 12;
/** Desired minimum screen-space hit-target size for touch (px) */
const TOUCH_MIN_SCREEN_PX = 44;
/**
* Hard ceiling for the world-space pin size, in CSS pixels.
* At very low zoom, `TOUCH_MIN_SCREEN_PX / zoom` would otherwise produce
* massive overlays that cover the whole board.
*/
const PIN_WORLD_MAX = 28;
interface PinInfo {
name: string;
x: number; // CSS pixels
y: number; // CSS pixels
signals?: Array<{ type: string; signal?: string }>;
}
interface PinOverlayProps {
componentId: string;
componentX: number;
componentY: number;
onPinClick: (componentId: string, pinName: string, x: number, y: number) => void;
showPins: boolean;
/** 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> = ({
componentId,
componentX,
componentY,
onPinClick,
showPins,
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(() => {
const tryRead = () => {
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;
};
if (!tryRead()) {
// Retry once after a tick in case the element sets pinInfo asynchronously (e.g. via useEffect)
const t = setTimeout(tryRead, 50);
return () => clearTimeout(t);
}
}, [componentId, rotation]);
if (!showPins || pins.length === 0) {
return null;
}
// On touch-primary devices, compute world-space size so the pin is at least
// TOUCH_MIN_SCREEN_PX on screen — but clamp to PIN_WORLD_MAX so very low
// zoom levels can't produce gigantic overlays. On desktop, keep PIN_VISUAL.
const pinSize = isCoarse
? Math.min(PIN_WORLD_MAX, Math.max(PIN_VISUAL, TOUCH_MIN_SCREEN_PX / zoom))
: PIN_VISUAL;
const pinHalf = pinSize / 2;
return (
<div
style={{
position: 'absolute',
left: `${componentX + wrapperOffsetX}px`,
top: `${componentY + wrapperOffsetY}px`,
pointerEvents: 'none',
zIndex: 30, // Above wires (20) and components, below modals/dialogs (1000+)
}}
>
{pins.map((pin, index) => {
// Container origin in CANVAS = (componentX + wrapperOffsetX,
// componentY + wrapperOffsetY) — i.e. shifted INTO the wrapper
// by the wrapper's padding+border so pin.x can be added directly.
// The wrapper itself sits at (componentX, componentY), so its
// top-left in container-local coords is (-wrapperOffsetX,
// -wrapperOffsetY). CSS rotates around the wrapper's centre.
let pinX = pin.x;
let pinY = pin.y;
const angle = ((rotation % 360) + 360) % 360;
if (angle !== 0 && wrapperBox) {
const wrapperLeftLocal = -wrapperOffsetX;
const wrapperTopLocal = -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
key={`${pin.name}-${index}`}
data-pin-overlay="true"
onClick={(e) => {
e.stopPropagation();
onPinClick(
componentId,
pin.name,
componentX + wrapperOffsetX + pinX,
componentY + wrapperOffsetY + pinY,
);
}}
onTouchEnd={(e) => {
e.stopPropagation();
e.preventDefault();
onPinClick(
componentId,
pin.name,
componentX + wrapperOffsetX + pinX,
componentY + wrapperOffsetY + pinY,
);
}}
style={{
position: 'absolute',
left: `${pinX - pinHalf}px`,
top: `${pinY - pinHalf}px`,
width: `${pinSize}px`,
height: `${pinSize}px`,
borderRadius: '3px',
backgroundColor: 'rgba(0, 200, 255, 0.8)',
border: '1.5px solid white',
cursor: 'crosshair',
pointerEvents: 'all',
transition: 'all 0.15s',
touchAction: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0, 255, 100, 1)';
e.currentTarget.style.transform = 'scale(1.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'rgba(0, 200, 255, 0.8)';
e.currentTarget.style.transform = 'scale(1)';
}}
title={pin.name}
/>
);
})}
</div>
);
};