diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 043853d..bb25286 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -420,11 +420,98 @@ const NODE_SYMBOL_WIDTH = 112; const NODE_SYMBOL_HEIGHT = 84; const SYMBOL_VIEWBOX_WIDTH = 64; const SYMBOL_VIEWBOX_HEIGHT = 48; -const CONTACT_SNAP_RADIUS_PX = 24; +const SYMBOL_ENVELOPE_PADDING_PX = 2; +const CONTACT_SNAP_RADIUS_PX = 12; const CONTACT_DISCONNECT_DISTANCE_PX = 8; const CONTACT_GEOMETRY_TOLERANCE = 0.5; const CONTACT_DELETE_SEPARATION = 16; +type SymbolEnvelope = { + left: number; + top: number; + width: number; + height: number; +}; + +const DEFAULT_SYMBOL_ENVELOPE: SymbolEnvelope = { + left: 0, + top: 0, + width: NODE_SYMBOL_WIDTH, + height: NODE_SYMBOL_HEIGHT, +}; + +function measuredSymbolEnvelope(svg: SVGSVGElement): SymbolEnvelope { + const viewBox = svg.viewBox.baseVal; + const geometryBounds = svg.getBBox(); + let bounds: Pick = geometryBounds; + try { + const paintBounds = svg.getBBox({ + fill: true, + stroke: true, + markers: true, + clipped: false, + }); + const includesPaintOutsideGeometry = + paintBounds.x < geometryBounds.x || + paintBounds.y < geometryBounds.y || + paintBounds.x + paintBounds.width > + geometryBounds.x + geometryBounds.width || + paintBounds.y + paintBounds.height > + geometryBounds.y + geometryBounds.height; + if (includesPaintOutsideGeometry) { + bounds = paintBounds; + } + } catch { + // Older SVG implementations may not support the getBBox options object. + } + if (bounds === geometryBounds) { + const maximumStrokeWidth = Array.from(svg.querySelectorAll("*")) + .map((element) => getComputedStyle(element)) + .filter((style) => style.stroke !== "none") + .reduce( + (maximum, style) => + Math.max(maximum, Number.parseFloat(style.strokeWidth) || 0), + 0, + ); + if (maximumStrokeWidth > 0) { + const strokeRadius = maximumStrokeWidth / 2; + bounds = { + x: geometryBounds.x - strokeRadius, + y: geometryBounds.y - strokeRadius, + width: geometryBounds.width + maximumStrokeWidth, + height: geometryBounds.height + maximumStrokeWidth, + }; + } + } + if ( + viewBox.width <= 0 || + viewBox.height <= 0 || + bounds.width <= 0 || + bounds.height <= 0 + ) { + return DEFAULT_SYMBOL_ENVELOPE; + } + + const scaleX = NODE_SYMBOL_WIDTH / viewBox.width; + const scaleY = NODE_SYMBOL_HEIGHT / viewBox.height; + const left = (bounds.x - viewBox.x) * scaleX - SYMBOL_ENVELOPE_PADDING_PX; + const top = (bounds.y - viewBox.y) * scaleY - SYMBOL_ENVELOPE_PADDING_PX; + const right = + (bounds.x + bounds.width - viewBox.x) * scaleX + + SYMBOL_ENVELOPE_PADDING_PX; + const bottom = + (bounds.y + bounds.height - viewBox.y) * scaleY + + SYMBOL_ENVELOPE_PADDING_PX; + const roundPixel = (value: number) => Math.round(value * 100) / 100; + + return { + left: roundPixel(left), + top: roundPixel(top), + width: roundPixel(Math.max(1, right - left)), + height: roundPixel(Math.max(1, bottom - top)), + }; +} + function defaultModelingPaneLayout(): ModelingPaneLayout { return { paletteWidth: 230, @@ -1396,6 +1483,10 @@ function assignMissingAmesimGasDefinitionIndices( function SimulationComponentNode({ id, data, selected }: NodeProps) { const updateNodeInternals = useUpdateNodeInternals(); + const symbolContainerRef = useRef(null); + const [symbolEnvelope, setSymbolEnvelope] = useState( + DEFAULT_SYMBOL_ENVELOPE, + ); const rotation = normalizeNodeRotation(data.rotation); const mirrored = Boolean(data.mirrored); const vertical = rotation === 90 || rotation === 270; @@ -1412,6 +1503,27 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { + if (!hasDedicatedSymbol) { + return; + } + const svg = symbolContainerRef.current?.querySelector( + ".component-symbol-svg", + ); + if (!svg) { + return; + } + const nextEnvelope = measuredSymbolEnvelope(svg); + setSymbolEnvelope((currentEnvelope) => + currentEnvelope.left === nextEnvelope.left && + currentEnvelope.top === nextEnvelope.top && + currentEnvelope.width === nextEnvelope.width && + currentEnvelope.height === nextEnvelope.height + ? currentEnvelope + : nextEnvelope, + ); + }, [hasDedicatedSymbol, symbol]); + useEffect(() => { updateNodeInternals(id); }, [mirrored, rotation, id, updateNodeInternals]); @@ -1459,10 +1571,21 @@ function SimulationComponentNode({ id, data, selected }: NodeProps