The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx

564 lines
21 KiB

import { css } from '@emotion/css';
import React, { 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';
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<ConnectionState | undefined>(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 } = 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;
return (
<svg className={styles.connection} key={idx}>
<g onClick={() => selectConnection(v)}>
<defs>
<marker
id={CONNECTION_HEAD_ID_START}
markerWidth="10"
markerHeight="7"
refX="0"
refY="3.5"
orient="auto"
stroke={strokeColor}
>
<polygon points="10 0, 0 3.5, 10 7" fill={strokeColor} />
</marker>
<marker
id={CONNECTION_HEAD_ID_END}
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
stroke={strokeColor}
>
<polygon points="0 0, 10 3.5, 0 7" fill={strokeColor} />
</marker>
</defs>
{vertices?.length ? (
<g>
<path
id={`${CONNECTION_LINE_ID}_transparent`}
d={pathString}
cursor={connectionCursorStyle}
pointerEvents="auto"
stroke="transparent"
strokeWidth={15}
fill={'none'}
style={isSelected ? selectedStyles : {}}
/>
<path
d={pathString}
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeDasharray={lineStyle}
fill={'none'}
markerEnd={markerEnd}
markerStart={markerStart}
/>
{isSelected && (
<g>
{vertices.map((value, index) => {
return (
<circle
id={CONNECTION_VERTEX_ID}
data-index={index}
key={`${CONNECTION_VERTEX_ID}${index}_${idx}`}
cx={value.x * xDist + xStart}
cy={value.y * yDist + yStart}
r={5}
stroke={strokeColor}
className={styles.vertex}
cursor={'crosshair'}
pointerEvents="auto"
/>
);
})}
{vertices.length < maximumVertices &&
addVertices.map((value, index) => {
return (
<circle
id={CONNECTION_VERTEX_ADD_ID}
data-index={index}
key={`${CONNECTION_VERTEX_ADD_ID}${index}_${idx}`}
cx={value.x * xDist + xStart}
cy={value.y * yDist + yStart}
r={4}
stroke={strokeColor}
className={styles.addVertex}
cursor={'crosshair'}
pointerEvents="auto"
/>
);
})}
</g>
)}
</g>
) : (
<g>
<line
id={`${CONNECTION_LINE_ID}_transparent`}
cursor={connectionCursorStyle}
pointerEvents="auto"
stroke="transparent"
strokeWidth={15}
style={isSelected ? selectedStyles : {}}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
/>
<line
id={CONNECTION_LINE_ID}
stroke={strokeColor}
pointerEvents="auto"
strokeWidth={strokeWidth}
markerEnd={markerEnd}
markerStart={markerStart}
strokeDasharray={lineStyle}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
cursor={connectionCursorStyle}
/>
{isSelected && (
<circle
id={CONNECTION_VERTEX_ADD_ID}
data-index={0}
cx={midpoint.x}
cy={midpoint.y}
r={4}
stroke={strokeColor}
className={styles.addVertex}
cursor={'crosshair'}
pointerEvents="auto"
/>
)}
</g>
)}
</g>
</svg>
);
})
);
};
return (
<>
<svg ref={setSVGRef} className={styles.editorSVG}>
<defs>
<marker
id={EDITOR_HEAD_ID}
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
stroke={defaultArrowColor}
>
<polygon points="0 0, 10 3.5, 0 7" fill={defaultArrowColor} />
</marker>
</defs>
<line ref={setLineRef} stroke={defaultArrowColor} strokeWidth={2} markerEnd={`url(#${EDITOR_HEAD_ID})`} />
</svg>
<svg ref={setSVGVertexRef} className={styles.editorSVG}>
<path
ref={setVertexPathRef}
stroke={defaultArrowColor}
strokeWidth={2}
strokeDasharray={'5, 5'}
fill={'none'}
/>
<circle ref={setVertexRef} stroke={defaultArrowColor} r={4} className={styles.vertex} />
</svg>
{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,
}),
});