diff --git a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts index c29f34f2170..a1b30c0904b 100644 --- a/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/canvas/panelcfg/x/CanvasPanelCfg_types.gen.ts @@ -83,8 +83,13 @@ export interface CanvasConnection { source: ConnectionCoordinates; target: ConnectionCoordinates; targetName?: string; + vertices?: Array; } +export const defaultCanvasConnection: Partial = { + vertices: [], +}; + export interface CanvasElementOptions { background?: BackgroundConfig; border?: LineConfig; diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index cdd1939f1a7..99b51e4df37 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -49,6 +49,7 @@ export interface CanvasConnection { path: ConnectionPath; color?: ColorDimensionConfig; size?: ScaleDimensionConfig; + vertices?: ConnectionCoordinates[]; // See https://github.com/anseki/leader-line#options for more examples of more properties } diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx index e753b732e31..526f2a245a0 100644 --- a/public/app/features/canvas/runtime/scene.tsx +++ b/public/app/features/canvas/runtime/scene.tsx @@ -28,7 +28,11 @@ import { } from 'app/features/dimensions/utils'; import { CanvasContextMenu } from 'app/plugins/panel/canvas/components/CanvasContextMenu'; import { CanvasTooltip } from 'app/plugins/panel/canvas/components/CanvasTooltip'; -import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; +import { + CONNECTION_ANCHOR_DIV_ID, + CONNECTION_VERTEX_ADD_ID, + CONNECTION_VERTEX_ID, +} from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors'; import { Connections } from 'app/plugins/panel/canvas/components/connections/Connections'; import { AnchorPoint, CanvasTooltipPayload, LayerActionID } from 'app/plugins/panel/canvas/types'; import { getParent, getTransformInstance } from 'app/plugins/panel/canvas/utils'; @@ -615,6 +619,20 @@ export class Scene { return; } + // If selected target is a vertex, eject to handle vertex event + if (selectedTarget.id === CONNECTION_VERTEX_ID) { + this.connections.handleVertexDragStart(selectedTarget); + event.stop(); + return; + } + + // If selected target is an add vertex point, eject to handle add vertex event + if (selectedTarget.id === CONNECTION_VERTEX_ADD_ID) { + this.connections.handleVertexAddDragStart(selectedTarget); + event.stop(); + return; + } + const isTargetMoveableElement = this.moveable!.isMoveableElement(selectedTarget) || targets.some((target) => target === selectedTarget || target.contains(selectedTarget)); diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx index cbd13b41e24..879a1a44cff 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionAnchors.tsx @@ -15,6 +15,8 @@ type Props = { export const CONNECTION_ANCHOR_DIV_ID = 'connectionControl'; export const CONNECTION_ANCHOR_ALT = 'connection anchor'; export const CONNECTION_ANCHOR_HIGHLIGHT_OFFSET = 8; +export const CONNECTION_VERTEX_ID = 'vertex'; +export const CONNECTION_VERTEX_ADD_ID = 'vertexAdd'; const ANCHOR_PADDING = 3; diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index 4506f7b13e2..f0d7c960a42 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -6,19 +6,38 @@ import { useStyles2 } from '@grafana/ui'; import { config } from 'app/core/config'; import { Scene } from 'app/features/canvas/runtime/scene'; +import { ConnectionCoordinates } from '../../panelcfg.gen'; import { ConnectionState } from '../../types'; -import { calculateCoordinates, getConnectionStyles, getParentBoundingClientRect } from '../../utils'; +import { + calculateAbsoluteCoords, + calculateCoordinates, + calculateMidpoint, + getConnectionStyles, + getParentBoundingClientRect, +} from '../../utils'; + +import { CONNECTION_VERTEX_ADD_ID, CONNECTION_VERTEX_ID } from './ConnectionAnchors'; 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, scene }: Props) => { +export const ConnectionSVG = ({ + setSVGRef, + setLineRef, + setSVGVertexRef, + setVertexPathRef, + setVertexRef, + scene, +}: Props) => { const styles = useStyles2(getStyles); const headId = Date.now() + '_' + idCounter++; @@ -26,6 +45,7 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { const EDITOR_HEAD_ID = useMemo(() => `editorHead-${headId}`, [headId]); const defaultArrowColor = config.theme2.colors.text.primary; const defaultArrowSize = 2; + const maximumVertices = 10; const [selectedConnection, setSelectedConnection] = useState(undefined); @@ -101,7 +121,7 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { // Figure out target and then target's relative coordinates drawing (if no target do parent) const renderConnections = () => { return scene.connections.state.map((v, idx) => { - const { source, target, info } = v; + const { source, target, info, vertices } = v; const sourceRect = source.div?.getBoundingClientRect(); const parent = source.div?.parentElement; const transformScale = scene.scale; @@ -112,6 +132,7 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { } const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target, transformScale); + const midpoint = calculateMidpoint(x1, y1, x2, y2); const { strokeColor, strokeWidth } = getConnectionStyles(info, scene, defaultArrowSize); @@ -122,6 +143,30 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { const CONNECTION_HEAD_ID = `connectionHead-${headId + Math.random()}`; + // 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; + pathString += `L${x * (x2 - x1) + x1} ${y * (y2 - y1) + y1} `; + if (index === 0) { + // For first vertex + addVertices.push(calculateMidpoint(0, 0, x, y)); + } else { + // For all other vertices + const previousVertex = vertices[index - 1]; + addVertices.push(calculateMidpoint(previousVertex.x, previousVertex.y, x, y)); + } + if (index === vertices.length - 1) { + // For last vertex + addVertices.push(calculateMidpoint(1, 1, x, y)); + } + }); + pathString += `L${x2} ${y2}`; + } + return ( selectConnection(v)}> @@ -138,30 +183,106 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { - - + {vertices?.length ? ( + + + + {isSelected && ( + + {vertices.map((value, index) => { + const { x, y } = calculateAbsoluteCoords(x1, y1, x2, y2, value.x, value.y); + return ( + + ); + })} + {vertices.length < maximumVertices && + addVertices.map((value, index) => { + const { x, y } = calculateAbsoluteCoords(x1, y1, x2, y2, value.x, value.y); + return ( + + ); + })} + + )} + + ) : ( + + + + {isSelected && ( + + )} + + )} ); @@ -186,6 +307,16 @@ export const ConnectionSVG = ({ setSVGRef, setLineRef, scene }: Props) => { + + + + {renderConnections()} ); @@ -207,4 +338,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ zIndex: 1000, pointerEvents: 'none', }), + vertex: css({ + fill: '#44aaff', + strokeWidth: 2, + }), + addVertex: css({ + fill: '#44aaff', + opacity: 0.5, + strokeWidth: 1, + }), }); diff --git a/public/app/plugins/panel/canvas/components/connections/Connections.tsx b/public/app/plugins/panel/canvas/components/connections/Connections.tsx index 0a62a007380..25eb19644f1 100644 --- a/public/app/plugins/panel/canvas/components/connections/Connections.tsx +++ b/public/app/plugins/panel/canvas/components/connections/Connections.tsx @@ -2,12 +2,18 @@ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { config } from '@grafana/runtime'; -import { CanvasConnection, ConnectionPath } from 'app/features/canvas'; +import { CanvasConnection, ConnectionCoordinates, ConnectionPath } from 'app/features/canvas'; import { ElementState } from 'app/features/canvas/runtime/element'; import { Scene } from 'app/features/canvas/runtime/scene'; import { ConnectionState } from '../../types'; -import { getConnections, getParentBoundingClientRect, isConnectionSource, isConnectionTarget } from '../../utils'; +import { + calculateCoordinates, + getConnections, + getParentBoundingClientRect, + isConnectionSource, + isConnectionTarget, +} from '../../utils'; import { CONNECTION_ANCHOR_ALT, ConnectionAnchors, CONNECTION_ANCHOR_HIGHLIGHT_OFFSET } from './ConnectionAnchors'; import { ConnectionSVG } from './ConnectionSVG'; @@ -17,9 +23,13 @@ export class Connections { connectionAnchorDiv?: HTMLDivElement; connectionSVG?: SVGElement; connectionLine?: SVGLineElement; + connectionSVGVertex?: SVGElement; + connectionVertexPath?: SVGPathElement; + connectionVertex?: SVGCircleElement; connectionSource?: ElementState; connectionTarget?: ElementState; isDrawingConnection?: boolean; + selectedVertexIndex?: number; didConnectionLeaveHighlight?: boolean; state: ConnectionState[] = []; readonly selection = new BehaviorSubject(undefined); @@ -62,6 +72,18 @@ export class Connections { this.connectionLine = connectionLine; }; + setConnectionSVGVertexRef = (connectionSVG: SVGSVGElement) => { + this.connectionSVGVertex = connectionSVG; + }; + + setConnectionVertexRef = (connectionVertex: SVGCircleElement) => { + this.connectionVertex = connectionVertex; + }; + + setConnectionVertexPathRef = (connectionVertexPath: SVGPathElement) => { + this.connectionVertexPath = connectionVertexPath; + }; + // Recursively find the first parent that is a canvas element findElementTarget = (element: Element): ElementState | undefined => { let elementTarget = undefined; @@ -251,6 +273,192 @@ export class Connections { } }; + // Handles mousemove and mouseup events when dragging an existing vertex + vertexListener = (event: MouseEvent) => { + event.preventDefault(); + + if (!(this.connectionVertex && this.scene.div && this.scene.div.parentElement)) { + return; + } + + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); + + if (!parentBoundingRect) { + return; + } + + const x = (event.pageX - parentBoundingRect.x) / transformScale ?? 0; + const y = (event.pageY - parentBoundingRect.y) / transformScale ?? 0; + + this.connectionVertex?.setAttribute('cx', `${x}`); + this.connectionVertex?.setAttribute('cy', `${y}`); + + const sourceRect = this.selection.value!.source.div!.getBoundingClientRect(); + + // calculate relative coordinates based on source and target coorindates of connection + const { x1, y1, x2, y2 } = calculateCoordinates( + sourceRect, + parentBoundingRect, + this.selection.value?.info!, + this.selection.value!.target, + transformScale + ); + + let vx1 = x1; + let vy1 = y1; + let vx2 = x2; + let vy2 = y2; + if (this.selection.value && this.selection.value.vertices) { + if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) { + vx1 += this.selection.value.vertices[this.selectedVertexIndex - 1].x * (x2 - x1); + vy1 += this.selection.value.vertices[this.selectedVertexIndex - 1].y * (y2 - y1); + } + if ( + this.selectedVertexIndex !== undefined && + this.selectedVertexIndex < this.selection.value.vertices.length - 1 + ) { + vx2 = this.selection.value.vertices[this.selectedVertexIndex + 1].x * (x2 - x1) + x1; + vy2 = this.selection.value.vertices[this.selectedVertexIndex + 1].y * (y2 - y1) + y1; + } + } + + // Display temporary vertex during drag + this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${x} ${y} L${vx2} ${vy2}`); + this.connectionSVGVertex!.style.display = 'block'; + + // Handle mouseup + if (!event.buttons) { + // Remove existing event listener + this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexListener); + this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexListener); + this.scene.selecto!.rootContainer!.style.cursor = 'auto'; + this.connectionSVGVertex!.style.display = 'none'; + + // call onChange here and update appropriate index of connection vertices array + const connectionIndex = this.selection.value?.index; + const vertexIndex = this.selectedVertexIndex; + + if (connectionIndex !== undefined && vertexIndex !== undefined) { + const currentSource = this.scene.connections.state[connectionIndex].source; + if (currentSource.options.connections) { + const currentConnections = [...currentSource.options.connections]; + if (currentConnections[connectionIndex].vertices) { + const currentVertices = [...currentConnections[connectionIndex].vertices!]; + const currentVertex = { ...currentVertices[vertexIndex] }; + + currentVertex.x = (x - x1) / (x2 - x1); + currentVertex.y = (y - y1) / (y2 - y1); + + currentVertices[vertexIndex] = currentVertex; + currentConnections[connectionIndex] = { + ...currentConnections[connectionIndex], + vertices: currentVertices, + }; + + // Update save model + currentSource.onChange({ ...currentSource.options, connections: currentConnections }); + this.updateState(); + this.scene.save(); + } + } + } + } + }; + + // Handles mousemove and mouseup events when dragging a new vertex + vertexAddListener = (event: MouseEvent) => { + event.preventDefault(); + + if (!(this.connectionVertex && this.scene.div && this.scene.div.parentElement)) { + return; + } + + const transformScale = this.scene.scale; + const parentBoundingRect = getParentBoundingClientRect(this.scene); + + if (!parentBoundingRect) { + return; + } + + const x = (event.pageX - parentBoundingRect.x) / transformScale ?? 0; + const y = (event.pageY - parentBoundingRect.y) / transformScale ?? 0; + + this.connectionVertex?.setAttribute('cx', `${x}`); + this.connectionVertex?.setAttribute('cy', `${y}`); + + const sourceRect = this.selection.value!.source.div!.getBoundingClientRect(); + + // calculate relative coordinates based on source and target coorindates of connection + const { x1, y1, x2, y2 } = calculateCoordinates( + sourceRect, + parentBoundingRect, + this.selection.value?.info!, + this.selection.value!.target, + transformScale + ); + + let vx1 = x1; + let vy1 = y1; + let vx2 = x2; + let vy2 = y2; + if (this.selection.value && this.selection.value.vertices) { + if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex > 0) { + vx1 += this.selection.value.vertices[this.selectedVertexIndex - 1].x * (x2 - x1); + vy1 += this.selection.value.vertices[this.selectedVertexIndex - 1].y * (y2 - y1); + } + if (this.selectedVertexIndex !== undefined && this.selectedVertexIndex < this.selection.value.vertices.length) { + vx2 = this.selection.value.vertices[this.selectedVertexIndex].x * (x2 - x1) + x1; + vy2 = this.selection.value.vertices[this.selectedVertexIndex].y * (y2 - y1) + y1; + } + } + + // Display temporary vertex during drag + this.connectionVertexPath?.setAttribute('d', `M${vx1} ${vy1} L${x} ${y} L${vx2} ${vy2}`); + this.connectionSVGVertex!.style.display = 'block'; + + // Handle mouseup + if (!event.buttons) { + // Remove existing event listener + this.scene.selecto?.rootContainer?.removeEventListener('mousemove', this.vertexAddListener); + this.scene.selecto?.rootContainer?.removeEventListener('mouseup', this.vertexAddListener); + this.scene.selecto!.rootContainer!.style.cursor = 'auto'; + this.connectionSVGVertex!.style.display = 'none'; + + // call onChange here and insert new vertex at appropriate index of connection vertices array + const connectionIndex = this.selection.value?.index; + const vertexIndex = this.selectedVertexIndex; + + if (connectionIndex !== undefined && vertexIndex !== undefined) { + const currentSource = this.scene.connections.state[connectionIndex].source; + if (currentSource.options.connections) { + const currentConnections = [...currentSource.options.connections]; + const newVertex = { x: (x - x1) / (x2 - x1), y: (y - y1) / (y2 - y1) }; + if (currentConnections[connectionIndex].vertices) { + const currentVertices = [...currentConnections[connectionIndex].vertices!]; + currentVertices.splice(vertexIndex, 0, newVertex); + currentConnections[connectionIndex] = { + ...currentConnections[connectionIndex], + vertices: currentVertices, + }; + } else { + // For first vertex creation + const currentVertices: ConnectionCoordinates[] = [newVertex]; + currentConnections[connectionIndex] = { + ...currentConnections[connectionIndex], + vertices: currentVertices, + }; + } + + // Update save model + currentSource.onChange({ ...currentSource.options, connections: currentConnections }); + this.updateState(); + this.scene.save(); + } + } + } + }; + handleConnectionDragStart = (selectedTarget: HTMLElement, clientX: number, clientY: number) => { this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; if (this.connectionSVG && this.connectionLine && this.scene.div && this.scene.div.parentElement) { @@ -283,6 +491,26 @@ export class Connections { this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.connectionListener); }; + // Add event listener at root container during existing vertex drag + handleVertexDragStart = (selectedTarget: HTMLElement) => { + // Get vertex index from selected target data + this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index')); + this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; + + this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexListener); + this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexListener); + }; + + // Add event listener at root container during creation of new vertex + handleVertexAddDragStart = (selectedTarget: HTMLElement) => { + // Get vertex index from selected target data + this.selectedVertexIndex = Number(selectedTarget.getAttribute('data-index')); + this.scene.selecto!.rootContainer!.style.cursor = 'crosshair'; + + this.scene.selecto?.rootContainer?.addEventListener('mousemove', this.vertexAddListener); + this.scene.selecto?.rootContainer?.addEventListener('mouseup', this.vertexAddListener); + }; + onChange = (current: ConnectionState, update: CanvasConnection) => { const connections = current.source.options.connections?.splice(0) ?? []; connections[current.index] = update; @@ -299,7 +527,14 @@ export class Connections { return ( <> - + ); } diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index c68db38ff81..99f6b864987 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -70,6 +70,7 @@ composableKinds: PanelCfg: { path: ConnectionPath color?: ui.ColorDimensionConfig size?: ui.ScaleDimensionConfig + vertices?: [...ConnectionCoordinates] } @cuetsy(kind="interface") CanvasElementOptions: { name: string diff --git a/public/app/plugins/panel/canvas/panelcfg.gen.ts b/public/app/plugins/panel/canvas/panelcfg.gen.ts index 16b6b3c83af..fdd369e1a7a 100644 --- a/public/app/plugins/panel/canvas/panelcfg.gen.ts +++ b/public/app/plugins/panel/canvas/panelcfg.gen.ts @@ -81,8 +81,13 @@ export interface CanvasConnection { source: ConnectionCoordinates; target: ConnectionCoordinates; targetName?: string; + vertices?: Array; } +export const defaultCanvasConnection: Partial = { + vertices: [], +}; + export interface CanvasElementOptions { background?: BackgroundConfig; border?: LineConfig; diff --git a/public/app/plugins/panel/canvas/types.ts b/public/app/plugins/panel/canvas/types.ts index aea23f571af..19df754159c 100644 --- a/public/app/plugins/panel/canvas/types.ts +++ b/public/app/plugins/panel/canvas/types.ts @@ -1,6 +1,8 @@ import { CanvasConnection } from '../../../features/canvas'; import { ElementState } from '../../../features/canvas/runtime/element'; +import { ConnectionCoordinates } from './panelcfg.gen'; + export enum LayerActionID { Delete = 'delete', Duplicate = 'duplicate', @@ -39,4 +41,5 @@ export interface ConnectionState { source: ElementState; target: ElementState; info: CanvasConnection; + vertices?: ConnectionCoordinates[]; } diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index e64f0e2177c..71cf411c3e4 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -290,6 +290,7 @@ export function getConnections(sceneByName: Map) { source: v, target, info: c, + vertices: c.vertices ?? undefined, }); } }); @@ -347,6 +348,21 @@ export const calculateCoordinates = ( return { x1, y1, x2, y2 }; }; +export const calculateMidpoint = (x1: number, y1: number, x2: number, y2: number) => { + return { x: (x1 + x2) / 2, y: (y1 + y2) / 2 }; +}; + +export const calculateAbsoluteCoords = ( + x1: number, + y1: number, + x2: number, + y2: number, + valueX: number, + valueY: number +) => { + return { x: valueX * (x2 - x1) + x1, y: valueY * (y2 - y1) + y1 }; +}; + // @TODO revisit, currently returning last row index for field export const getRowIndex = (fieldName: string | undefined, scene: Scene) => { if (fieldName) {