import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { config } from 'app/core/config'; import { ConnectionDirection } from 'app/features/canvas/element'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionCoordinates } from '../../panelcfg.gen'; import { ConnectionState } from '../../types'; import { calculateAngle, calculateCoordinates, calculateDistance, calculateMidpoint, getConnectionStyles, getParentBoundingClientRect, } from '../../utils'; import { CONNECTION_VERTEX_ADD_ID, CONNECTION_VERTEX_ID } from './Connections'; type Props = { setSVGRef: (anchorElement: SVGSVGElement) => void; setLineRef: (anchorElement: SVGLineElement) => void; setSVGVertexRef: (anchorElement: SVGSVGElement) => void; setVertexPathRef: (anchorElement: SVGPathElement) => void; setVertexRef: (anchorElement: SVGCircleElement) => void; scene: Scene; }; let idCounter = 0; const htmlElementTypes = ['input', 'textarea']; export const ConnectionSVG = ({ setSVGRef, setLineRef, setSVGVertexRef, setVertexPathRef, setVertexRef, scene, }: Props) => { const styles = useStyles2(getStyles); const headId = Date.now() + '_' + idCounter++; const CONNECTION_LINE_ID = useMemo(() => `connectionLineId-${headId}`, [headId]); const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]); const defaultArrowColor = config.theme2.colors.text.primary; const defaultArrowSize = 2; const defaultArrowDirection = ConnectionDirection.Forward; const maximumVertices = 10; const [selectedConnection, setSelectedConnection] = useState(undefined); // Need to use ref to ensure state is not stale in event handler const selectedConnectionRef = useRef(selectedConnection); useEffect(() => { selectedConnectionRef.current = selectedConnection; }); useEffect(() => { if (scene.panel.context.instanceState?.selectedConnection) { setSelectedConnection(scene.panel.context.instanceState?.selectedConnection); } }, [scene.panel.context.instanceState?.selectedConnection]); const onKeyUp = (e: KeyboardEvent) => { const target = e.target; if (!(target instanceof HTMLElement)) { return; } if (htmlElementTypes.indexOf(target.nodeName.toLowerCase()) > -1) { return; } // Backspace (8) or delete (46) if (e.keyCode === 8 || e.keyCode === 46) { if (selectedConnectionRef.current && selectedConnectionRef.current.source) { selectedConnectionRef.current.source.options.connections = selectedConnectionRef.current.source.options.connections?.filter( (connection) => connection !== selectedConnectionRef.current?.info ); selectedConnectionRef.current.source.onChange(selectedConnectionRef.current.source.options); setSelectedConnection(undefined); scene.connections.select(undefined); scene.connections.updateState(); scene.save(); } } else { // Prevent removing event listener if key is not delete return; } document.removeEventListener('keyup', onKeyUp); scene.selecto!.rootContainer!.removeEventListener('click', clearSelectedConnection); }; const clearSelectedConnection = (event: MouseEvent) => { const eventTarget = event.target; const shouldResetSelectedConnection = !( eventTarget instanceof SVGLineElement && eventTarget.id === CONNECTION_LINE_ID ); if (shouldResetSelectedConnection) { setSelectedConnection(undefined); scene.connections.select(undefined); } }; const selectConnection = (connection: ConnectionState) => { if (scene.isEditingEnabled) { setSelectedConnection(connection); scene.connections.select(connection); document.addEventListener('keyup', onKeyUp); scene.selecto!.rootContainer!.addEventListener('click', clearSelectedConnection); } }; // Figure out target and then target's relative coordinates drawing (if no target do parent) const renderConnections = () => { return ( scene.connections.state // Render selected connection last, ensuring it is above other connections .sort((_a, b) => (selectedConnection === b && scene.panel.context.instanceState.selectedConnection ? -1 : 0)) .map((v, idx) => { const { source, target, info, vertices, index } = v; const sourceRect = source.div?.getBoundingClientRect(); const parent = source.div?.parentElement; const transformScale = scene.scale; const parentRect = getParentBoundingClientRect(scene); if (!sourceRect || !parent || !parentRect) { return; } const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target, transformScale); let { xStart, yStart, xEnd, yEnd } = { xStart: x1, yStart: y1, xEnd: x2, yEnd: y2 }; if (v.sourceOriginal && v.targetOriginal) { xStart = v.sourceOriginal.x; yStart = v.sourceOriginal.y; xEnd = v.targetOriginal.x; yEnd = v.targetOriginal.y; } else if (source.options.connections) { // If original source or target coordinates are not set for the current connection, set them if ( !source.options.connections[index].sourceOriginal || !source.options.connections[index].targetOriginal ) { source.options.connections[index].sourceOriginal = { x: x1, y: y1 }; source.options.connections[index].targetOriginal = { x: x2, y: y2 }; } } const midpoint = calculateMidpoint(x1, y1, x2, y2); const xDist = xEnd - xStart; const yDist = yEnd - yStart; const { strokeColor, strokeWidth, strokeRadius, arrowDirection, lineStyle, shouldAnimate } = getConnectionStyles(info, scene, defaultArrowSize, defaultArrowDirection); const isSelected = selectedConnection === v && scene.panel.context.instanceState.selectedConnection; const connectionCursorStyle = scene.isEditingEnabled ? 'grab' : ''; const selectedStyles = { stroke: '#44aaff', strokeOpacity: 0.6, strokeWidth: strokeWidth + 5 }; const CONNECTION_HEAD_ID_START = `connectionHeadStart-${headId + Math.random()}`; const CONNECTION_HEAD_ID_END = `connectionHeadEnd-${headId + Math.random()}`; const radius = strokeRadius; // Create vertex path and populate array of add vertex controls const addVertices: ConnectionCoordinates[] = []; let pathString = `M${x1} ${y1} `; if (vertices?.length) { vertices.map((vertex, index) => { const x = vertex.x; const y = vertex.y; // Convert vertex relative coordinates to scene coordinates const X = x * xDist + xStart; const Y = y * yDist + yStart; // Initialize coordinates for first arc control point let xa = X; let ya = Y; // Initialize coordinates for second arc control point let xb = X; let yb = Y; // Initialize half arc distance and segment angles let lHalfArc = 0; let angle1 = 0; let angle2 = 0; // Only calculate arcs if there is a radius if (radius) { if (index < vertices.length - 1) { const Xn = vertices[index + 1].x * xDist + xStart; const Yn = vertices[index + 1].y * yDist + yStart; if (index === 0) { // First vertex angle1 = calculateAngle(x1, y1, X, Y); angle2 = calculateAngle(X, Y, Xn, Yn); } else { // All vertices const previousVertex = vertices[index - 1]; const Xp = previousVertex.x * xDist + xStart; const Yp = previousVertex.y * yDist + yStart; angle1 = calculateAngle(Xp, Yp, X, Y); angle2 = calculateAngle(X, Y, Xn, Yn); } } else { // Last vertex if (index > 0) { // Not also the first vertex const previousVertex = vertices[index - 1]; const Xp = previousVertex.x * xDist + xStart; const Yp = previousVertex.y * yDist + yStart; angle1 = calculateAngle(Xp, Yp, X, Y); } else { angle1 = calculateAngle(x1, y1, X, Y); } angle2 = calculateAngle(X, Y, x2, y2); } // Calculate angle between two segments where arc will be placed const theta = angle2 - angle1; //radians // Attempt to determine if arc is counter clockwise (ccw) const ccw = theta < 0; // Half arc is used for arc control points lHalfArc = radius * Math.tan(theta / 2); if (ccw) { lHalfArc *= -1; } } if (index === 0) { // For first vertex addVertices.push( calculateMidpoint((x1 - xStart) / (xEnd - xStart), (y1 - yStart) / (yEnd - yStart), x, y) ); // Only calculate arcs if there is a radius if (radius) { // Length of segment const lSegment = calculateDistance(X, Y, x1, y1); if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) { // Limit curve control points to mid segment lHalfArc = 0.5 * lSegment; } // Default next point to last point let Xn = x2; let Yn = y2; if (index < vertices.length - 1) { // Not also the last point const nextVertex = vertices[index + 1]; Xn = nextVertex.x * xDist + xStart; Yn = nextVertex.y * yDist + yStart; } // Length of next segment const lSegmentNext = calculateDistance(X, Y, Xn, Yn); if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegmentNext)) { // Limit curve control points to mid segment lHalfArc = 0.5 * lSegmentNext; } // Calculate arc control points const lDelta = lSegment - lHalfArc; xa = Math.round(lDelta * Math.cos(angle1) + x1); ya = Math.round(lDelta * Math.sin(angle1) + y1); xb = Math.round(lHalfArc * Math.cos(angle2) + X); yb = Math.round(lHalfArc * Math.sin(angle2) + Y); // Check if arc control points are inside of segment, otherwise swap sign if ((xa > X && xa > x1) || (xa < X && xa < x1)) { xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + x1; ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + y1; xb = -lHalfArc * Math.cos(angle2) + X; yb = -lHalfArc * Math.sin(angle2) + Y; } } } else { // For all other vertices const previousVertex = vertices[index - 1]; addVertices.push(calculateMidpoint(previousVertex.x, previousVertex.y, x, y)); // Only calculate arcs if there is a radius if (radius) { // Convert previous vertex relative coorindates to scene coordinates const Xp = previousVertex.x * xDist + xStart; const Yp = previousVertex.y * yDist + yStart; // Length of segment const lSegment = calculateDistance(X, Y, Xp, Yp); if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegment)) { // Limit curve control points to mid segment lHalfArc = 0.5 * lSegment; } // Default next point to last point let Xn = x2; let Yn = y2; if (index < vertices.length - 1) { // Not also the last point const nextVertex = vertices[index + 1]; Xn = nextVertex.x * xDist + xStart; Yn = nextVertex.y * yDist + yStart; } // Length of next segment const lSegmentNext = calculateDistance(X, Y, Xn, Yn); if (Math.abs(lHalfArc) > 0.5 * Math.abs(lSegmentNext)) { // Limit curve control points to mid segment lHalfArc = 0.5 * lSegmentNext; } // Calculate arc control points const lDelta = lSegment - lHalfArc; xa = Math.round(lDelta * Math.cos(angle1) + Xp); ya = Math.round(lDelta * Math.sin(angle1) + Yp); xb = Math.round(lHalfArc * Math.cos(angle2) + X); yb = Math.round(lHalfArc * Math.sin(angle2) + Y); // Check if arc control points are inside of segment, otherwise swap sign if ((xa > X && xa > Xp) || (xa < X && xa < Xp)) { xa = (lDelta + 2 * lHalfArc) * Math.cos(angle1) + Xp; ya = (lDelta + 2 * lHalfArc) * Math.sin(angle1) + Yp; xb = -lHalfArc * Math.cos(angle2) + X; yb = -lHalfArc * Math.sin(angle2) + Y; } } } if (index === vertices.length - 1) { // For last vertex only addVertices.push( calculateMidpoint((x2 - xStart) / (xEnd - xStart), (y2 - yStart) / (yEnd - yStart), x, y) ); } // Add segment to path pathString += `L${xa} ${ya} `; if (lHalfArc !== 0) { // Add arc if applicable pathString += `Q ${X} ${Y} ${xb} ${yb} `; } }); // Add last segment pathString += `L${x2} ${y2}`; } const markerStart = arrowDirection === ConnectionDirection.Reverse || arrowDirection === ConnectionDirection.Both ? `url(#${CONNECTION_HEAD_ID_START})` : undefined; const markerEnd = arrowDirection === ConnectionDirection.Forward || arrowDirection === ConnectionDirection.Both ? `url(#${CONNECTION_HEAD_ID_END})` : undefined; const getAnimationDirection = () => { let values = '100;0'; if (arrowDirection === ConnectionDirection.Reverse) { values = '0;100'; } return values; }; return ( selectConnection(v)}> {vertices?.length ? ( {shouldAnimate && ( )} {isSelected && ( {vertices.map((value, index) => { return ( ); })} {vertices.length < maximumVertices && addVertices.map((value, index) => { return ( ); })} )} ) : ( {shouldAnimate && ( )} {isSelected && ( )} )} ); }) ); }; return ( <> {renderConnections()} ); }; const getStyles = (theme: GrafanaTheme2) => ({ editorSVG: css({ position: 'absolute', pointerEvents: 'none', width: '100%', height: '100%', zIndex: 1000, display: 'none', }), connection: css({ position: 'absolute', width: '100%', height: '100%', zIndex: 1000, pointerEvents: 'none', }), vertex: css({ fill: '#44aaff', strokeWidth: 2, }), addVertex: css({ fill: '#44aaff', opacity: 0.5, strokeWidth: 1, }), });