From 0b4830ccfdb1faabdb68122ab27acd00f6fb1123 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Fri, 22 Mar 2024 12:32:12 -0700 Subject: [PATCH] Canvas: Add corner radius option (#84873) * Canvas: Add corner radius option * Update connection radius logic * Simplify angle calcs * Simplify math to be a bit more clear and efficient * Add checks for hyperbola behavior * Prevent arc calcs if no radius * Add comments for SOME clarity * Add some more clarity to comments * Fix linter issue * Check for segment overlap for first vertex * Update public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx * Add comment for calc clarity --- .../panelcfg/x/CanvasPanelCfg_types.gen.ts | 1 + public/app/features/canvas/element.ts | 1 + .../app/features/canvas/runtime/element.tsx | 4 + .../components/connections/ConnectionSVG.tsx | 162 +++++++++++++++++- .../components/connections/Connections.tsx | 2 +- .../panel/canvas/editor/connectionEditor.tsx | 1 + .../plugins/panel/canvas/editor/options.ts | 33 ++++ public/app/plugins/panel/canvas/panelcfg.cue | 5 +- .../app/plugins/panel/canvas/panelcfg.gen.ts | 1 + public/app/plugins/panel/canvas/utils.ts | 11 +- 10 files changed, 213 insertions(+), 8 deletions(-) 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 a1b30c0904b..79d92cd2b59 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 @@ -58,6 +58,7 @@ export interface BackgroundConfig { export interface LineConfig { color?: ui.ColorDimensionConfig; + radius?: number; width?: number; } diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index 7ffd3ba85a0..dbb35da18fe 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -58,6 +58,7 @@ export interface CanvasConnection { size?: ScaleDimensionConfig; lineStyle?: string; vertices?: ConnectionCoordinates[]; + radius?: ScaleDimensionConfig; direction?: ConnectionDirection; // See https://github.com/anseki/leader-line#options for more examples of more properties } diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx index 01f043582b1..0643f3af78f 100644 --- a/public/app/features/canvas/runtime/element.tsx +++ b/public/app/features/canvas/runtime/element.tsx @@ -358,6 +358,10 @@ export class ElementState implements LayerElement { } } + if (border && border.radius !== undefined) { + css.borderRadius = `${border.radius}px`; + } + this.dataStyle = css; this.applyLayoutStylesToDiv(); } diff --git a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx index c2c2a4adf0e..45c315a06c8 100644 --- a/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx +++ b/public/app/plugins/panel/canvas/components/connections/ConnectionSVG.tsx @@ -11,7 +11,9 @@ import { ConnectionCoordinates } from '../../panelcfg.gen'; import { ConnectionState } from '../../types'; import { calculateAbsoluteCoords, + calculateAngle, calculateCoordinates, + calculateDistance, calculateMidpoint, getConnectionStyles, getParentBoundingClientRect, @@ -135,8 +137,10 @@ export const ConnectionSVG = ({ const { x1, y1, x2, y2 } = calculateCoordinates(sourceRect, parentRect, info, target, transformScale); const midpoint = calculateMidpoint(x1, y1, x2, y2); + const xDist = x2 - x1; + const yDist = y2 - y1; - const { strokeColor, strokeWidth, arrowDirection, lineStyle } = getConnectionStyles( + const { strokeColor, strokeWidth, strokeRadius, arrowDirection, lineStyle } = getConnectionStyles( info, scene, defaultArrowSize, @@ -151,6 +155,7 @@ export const ConnectionSVG = ({ 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} `; @@ -158,20 +163,171 @@ export const ConnectionSVG = ({ vertices.map((vertex, index) => { const x = vertex.x; const y = vertex.y; - pathString += `L${x * (x2 - x1) + x1} ${y * (y2 - y1) + y1} `; + + // Convert vertex relative coordinates to scene coordinates + const X = x * xDist + x1; + const Y = y * yDist + y1; + + // 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 + x1; + const Yn = vertices[index + 1].y * yDist + y1; + 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 + x1; + const Yp = previousVertex.y * yDist + y1; + angle1 = calculateAngle(Xp, Yp, X, Y); + angle2 = calculateAngle(X, Y, Xn, Yn); + } + } else { + // Last vertex + let previousVertex = { x: 0, y: 0 }; + if (index > 0) { + // Not also the first vertex + previousVertex = vertices[index - 1]; + } + const Xp = previousVertex.x * xDist + x1; + const Yp = previousVertex.y * yDist + y1; + angle1 = calculateAngle(Xp, Yp, 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(0, 0, 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 + x1; + Yn = nextVertex.y * yDist + y1; + } + + // 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 = lDelta * Math.cos(angle1) + x1; + ya = lDelta * Math.sin(angle1) + y1; + xb = lHalfArc * Math.cos(angle2) + X; + yb = 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 + x1; + const Yp = previousVertex.y * yDist + y1; + + // 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 + x1; + Yn = nextVertex.y * yDist + y1; + } + + // 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 = lDelta * Math.cos(angle1) + Xp; + ya = lDelta * Math.sin(angle1) + Yp; + xb = lHalfArc * Math.cos(angle2) + X; + yb = 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 + // For last vertex only addVertices.push(calculateMidpoint(1, 1, 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}`; } diff --git a/public/app/plugins/panel/canvas/components/connections/Connections.tsx b/public/app/plugins/panel/canvas/components/connections/Connections.tsx index 2c1c4943ec2..17e2772b9d3 100644 --- a/public/app/plugins/panel/canvas/components/connections/Connections.tsx +++ b/public/app/plugins/panel/canvas/components/connections/Connections.tsx @@ -22,7 +22,7 @@ import { ConnectionSVG } from './ConnectionSVG'; export const CONNECTION_VERTEX_ID = 'vertex'; export const CONNECTION_VERTEX_ADD_ID = 'vertexAdd'; const CONNECTION_VERTEX_ORTHO_TOLERANCE = 0.05; // Cartesian ratio against vertical or horizontal tolerance -const CONNECTION_VERTEX_SNAP_TOLERANCE = 5; // Multi-segment snapping angle in degrees to trigger vertex removal +const CONNECTION_VERTEX_SNAP_TOLERANCE = (5 / 180) * Math.PI; // Multi-segment snapping angle in radians to trigger vertex removal export class Connections { scene: Scene; diff --git a/public/app/plugins/panel/canvas/editor/connectionEditor.tsx b/public/app/plugins/panel/canvas/editor/connectionEditor.tsx index 586393b1f32..8640bfe41ca 100644 --- a/public/app/plugins/panel/canvas/editor/connectionEditor.tsx +++ b/public/app/plugins/panel/canvas/editor/connectionEditor.tsx @@ -36,6 +36,7 @@ export function getConnectionEditor(opts: CanvasConnectionEditorOptions): Nested const ctx = { ...context, options: opts.connection.info }; optionBuilder.addColor(builder, ctx); optionBuilder.addSize(builder, ctx); + optionBuilder.addRadius(builder, ctx); optionBuilder.addDirection(builder, ctx); optionBuilder.addLineStyle(builder, ctx); }, diff --git a/public/app/plugins/panel/canvas/editor/options.ts b/public/app/plugins/panel/canvas/editor/options.ts index 0d3fa542999..fb461163dde 100644 --- a/public/app/plugins/panel/canvas/editor/options.ts +++ b/public/app/plugins/panel/canvas/editor/options.ts @@ -14,6 +14,7 @@ interface OptionSuppliers { addBorder: PanelOptionsSupplier; addColor: PanelOptionsSupplier; addSize: PanelOptionsSupplier; + addRadius: PanelOptionsSupplier; addDirection: PanelOptionsSupplier; addLineStyle: PanelOptionsSupplier; } @@ -90,6 +91,17 @@ export const optionBuilder: OptionSuppliers = { }, }); } + + builder.addSliderInput({ + category, + path: 'border.radius', + name: 'Radius', + defaultValue: 0, + settings: { + min: 0, + max: 60, + }, + }); }, addColor: (builder, context) => { @@ -129,6 +141,27 @@ export const optionBuilder: OptionSuppliers = { }); }, + addRadius: (builder, context) => { + const category = ['Radius']; + builder.addCustomEditor({ + category, + id: 'radius', + path: 'radius', + name: 'Radius', + editor: ScaleDimensionEditor, + settings: { + min: 0, + max: 200, + }, + defaultValue: { + // Configured values + fixed: 0, + min: 0, + max: 100, + }, + }); + }, + addDirection: (builder, context) => { const category = ['Arrow Direction']; builder.addRadio({ diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index 99f6b864987..dad78bd4fa3 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -52,8 +52,9 @@ composableKinds: PanelCfg: { } @cuetsy(kind="interface") LineConfig: { - color?: ui.ColorDimensionConfig - width?: float64 + color?: ui.ColorDimensionConfig + width?: float64 + radius?: float64 } @cuetsy(kind="interface") HttpRequestMethod: "GET" | "POST" | "PUT" @cuetsy(kind="enum", memberNames="GET|POST|PUT") diff --git a/public/app/plugins/panel/canvas/panelcfg.gen.ts b/public/app/plugins/panel/canvas/panelcfg.gen.ts index fdd369e1a7a..a37e7107777 100644 --- a/public/app/plugins/panel/canvas/panelcfg.gen.ts +++ b/public/app/plugins/panel/canvas/panelcfg.gen.ts @@ -56,6 +56,7 @@ export interface BackgroundConfig { export interface LineConfig { color?: ui.ColorDimensionConfig; + radius?: number; width?: number; } diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index 585802d3583..09adff4aa70 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -370,8 +370,14 @@ export const calculateAbsoluteCoords = ( return { x: valueX * (x2 - x1) + x1, y: valueY * (y2 - y1) + y1 }; }; +// Calculate angle between two points and return angle in radians export const calculateAngle = (x1: number, y1: number, x2: number, y2: number) => { - return (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; + return Math.atan2(y2 - y1, x2 - x1); +}; + +export const calculateDistance = (x1: number, y1: number, x2: number, y2: number) => { + //TODO add sqrt approx option + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); }; // @TODO revisit, currently returning last row index for field @@ -395,9 +401,10 @@ export const getConnectionStyles = ( const lastRowIndex = getRowIndex(info.size?.field, scene); const strokeColor = info.color ? scene.context.getColor(info.color).value() : defaultArrowColor; const strokeWidth = info.size ? scene.context.getScale(info.size).get(lastRowIndex) : defaultArrowSize; + const strokeRadius = info.radius ? scene.context.getScale(info.radius).get(lastRowIndex) : 0; const arrowDirection = info.direction ? info.direction : defaultArrowDirection; const lineStyle = info.lineStyle === LineStyle.Dashed ? StrokeDasharray.Dashed : StrokeDasharray.Solid; - return { strokeColor, strokeWidth, arrowDirection, lineStyle }; + return { strokeColor, strokeWidth, strokeRadius, arrowDirection, lineStyle }; }; export const getParentBoundingClientRect = (scene: Scene) => {