[FEATURE] Add canvas panel plugin - #729
Conversation
39b9803 to
d73e5e2
Compare
| @@ -0,0 +1,20 @@ | |||
| { | |||
| "kind": "Canvas", | |||
There was a problem hiding this comment.
It appears to me that this is a nodes panel rather than a canvas. Unless we are thinking about adding other things different from nodes in the future.
There was a problem hiding this comment.
Yes, We would like to develop it further with new things. I think adding background is one of the things that is already here and fits more into "canvas" than "nodes" plugin.
|
First of all, awesome job @adrianSepiol! Would be there an option to create a graph (nodes and connections) based on a query? for example network topology charts have queries that return a list of nodes and their connections. In that case could this panel create a graph from that information in addition to the manual placement of nodes? |
That is something we also discussed and would like to work on in the future, but the idea is to make this work with this spec for the first iteration and then add new functionalities gradually. |
| export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelEdgeLayerProps): ReactElement { | ||
| const nodes = spec.nodes ?? []; | ||
| const edges = spec.edges ?? []; | ||
| const nodeById = new Map(nodes.map((n) => [n.id, n])); |
There was a problem hiding this comment.
[NOTE] Rule: js-index-maps — Build Maps for Repeated Lookups
const nodeById = new Map(nodes.map((n) => [n.id, n])) is created on every render without useMemo. Since PanelEdgeLayer re-renders on zoom changes, this map is rebuilt every time even though spec.nodes hasn't changed.
Fix: Wrap in useMemo(() => new Map(nodes.map(...)), [nodes]).
|
@adrianSepiol
|
| const v = e.target.value; | ||
| onChange({ ...edge, sourceQueryIndex: v === '' ? undefined : Number(v) }); | ||
| }, |
There was a problem hiding this comment.
Should we validate sourceQueryIndex here?
Currently any none-empty value is converted with Number(V), which could potentially result in NaN, a negative value, or non-integer index.
There was a problem hiding this comment.
I've extracted parseNumberInput and use it in such cases.
| <TextField | ||
| select | ||
| label="Source anchor" | ||
| size="small" | ||
| value={edge.sourceAnchor ?? 'n'} | ||
| onChange={onSourceAnchorChange} | ||
| slotProps={{ select: { MenuProps: { PaperProps: { style: { maxHeight: 240 } } } } }} | ||
| > | ||
| {ANCHOR_OPTIONS.map((a) => ( | ||
| <MenuItem key={a} value={a}> | ||
| {a} | ||
| </MenuItem> | ||
| ))} | ||
| </TextField> |
| case 'selecting': { | ||
| const ids = applySelection(); | ||
| selectItems(ids); | ||
| endInteraction(); | ||
| break; | ||
| } |
There was a problem hiding this comment.
@shahrokni 👀 Test: Could it draw a selection box over an empty space of the canvas? Then what would happen?
| const onSvgDoubleClick = useCallback( | ||
| (event: MouseEvent<SVGSVGElement>): void => { | ||
| if (event.ctrlKey || event.metaKey) { | ||
| const boundingBox = nodeBoundingBox(displayNodes); | ||
| if (boundingBox) { | ||
| fitView(boundingBox, width, height); | ||
| } | ||
| } else { | ||
| resetPan(); | ||
| } | ||
| }, | ||
| [displayNodes, fitView, resetPan, width, height] | ||
| ); |
| const onKeyDown = useCallback( | ||
| (event: KeyboardEvent<SVGSVGElement>): void => { | ||
| if (event.key !== 'Delete' && event.key !== 'Backspace') { | ||
| return; | ||
| } | ||
| if (selectedIds.size > 0) { | ||
| deleteSelected(); | ||
| } | ||
| }, | ||
| [selectedIds, deleteSelected] | ||
| ); |
There was a problem hiding this comment.
Not with this PR, but as a follow up,
Probably we should consider a simple Undo, Redo support,
Imagine that you have selected many nodes in a huge canvas, and just unintentionally, you press Delete.
Would be painful to reorganize everything again.
| <EditorNode | ||
| key={node.id} | ||
| node={node} | ||
| isHovered={hoveredId === node.id} |
| // Copyright The Perses Authors | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| const canvasWidth = containerRef.current?.clientWidth ?? 0; | ||
| const cx = transform.invertX(canvasWidth / 2); | ||
| const cy = transform.invertY(CANVAS_HEIGHT / 2); | ||
| addNode(cx, cy); |
There was a problem hiding this comment.
Should we avoid adding a node when the container is unavailable or has zero width?
Falling back to 0 here could result in calculating an unintended canvas position.
Simply, if no canvas or no width, why should we add a node?
There was a problem hiding this comment.
I've added guard to check if canvas is mounted.
| <TextField | ||
| label="X" | ||
| size="small" | ||
| type="number" | ||
| value={Math.round(node.x)} | ||
| onChange={onIntFieldChange('x')} | ||
| sx={{ width: 80 }} | ||
| /> | ||
| <TextField | ||
| label="Y" | ||
| size="small" | ||
| type="number" | ||
| value={Math.round(node.y)} | ||
| onChange={onIntFieldChange('y')} | ||
| sx={{ width: 80 }} | ||
| /> | ||
| <TextField | ||
| label="Width" | ||
| size="small" | ||
| type="number" | ||
| value={Math.round(node.width)} | ||
| slotProps={{ htmlInput: { min: 8 } }} | ||
| onChange={onIntFieldChange('width', 8)} | ||
| sx={{ width: 80 }} | ||
| /> | ||
| <TextField | ||
| label="Height" | ||
| size="small" | ||
| type="number" | ||
| value={Math.round(node.height)} | ||
| slotProps={{ htmlInput: { min: 8 } }} | ||
| onChange={onIntFieldChange('height', 8)} | ||
| sx={{ width: 80 }} | ||
| /> |
There was a problem hiding this comment.
We have some duplication here. The Text Fields could be dynamically generated by a loop over and object-keys or array of X ,Y, Width, Height
There was a problem hiding this comment.
There are too many differences to have gain from loop especially after introducing point type but as I mentioned before I created component and use it here as well.
| sx={{ width: 80 }} | ||
| /> | ||
| </Stack> | ||
|
|
| // Copyright The Perses Authors | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); |
There was a problem hiding this comment.
@shahrokni Test 👀 Pre-ordered SVG layers. Could it be problamatic?
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // |
There was a problem hiding this comment.
@shahrokni Test 👀 Importance of Ctrl+ Click for select for larger canvases.
| @@ -0,0 +1,63 @@ | |||
| // Copyright The Perses Authors | |||
There was a problem hiding this comment.
@shahrokni Test 👀 Collision if it is a long list of legends?
4b84d3b to
7c250fb
Compare
784c15b to
2742a8a
Compare
@shahrokni This plugin functions as a canvas where you manually create nodes and edges, then connect queries to the edges/nodes of your choice. It supports time series queries. To test it, simply create a query, add nodes/edges, and connect them. Query results can be displayed as labels on nodes/edges, and you can define thresholds that dynamically change the color of nodes/edges based on the returned values. I've addressed all your comments, either through code changes or replies in the comments. Whenever you have time, feel free to take another look. |
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com> [ENHANCEMENT] update panel schema re-exports to use @perses-dev/plugin-system panelEditorSchema and buildPanelEditorSchema have moved from @perses-dev/spec to @perses-dev/plugin-system. Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
…mponent Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
…eeEndpoint properties Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
…points Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
… component for numeric inputs Signed-off-by: Adrian Sepiół <a.sepiol@sap.com>
cb0a0ba to
efe4e10
Compare
shahrokni
left a comment
There was a problem hiding this comment.
From my perspective, and for the comments I left, the issues are resolved.
ibakshay
left a comment
There was a problem hiding this comment.
I have texted the UI manually as well. Spectacular work!! 🚀 @adrianSepiol


Description
Related to: perses/perses#3845
Introduces the
canvaspanel plugin — a free-form network diagram editor for building weathermap-style dashboards. Users can place nodes, connect them with edges, and overlay background shapes or images. Node and edge colors can be driven by query-bound thresholds, and edge thickness can scale with metric values.Screenshots
Here's a walkthrough of the main interactions:
Example weather map created in canvas:
Adding a node:
Screen.Recording.2026-07-20.at.15.26.09.mov
After creating a node, you can specify its label and position, add a link, bind a query to it, and use the query value in the label or derive the color from thresholds.
Adding an edge:
Screen.Recording.2026-07-20.at.15.26.45.mov
Edges are created by dragging from one node to another. They can be bidirectional and, like nodes, can have a query bound to them.
Zoom, pan, and resize:
Screen.Recording.2026-07-20.at.15.32.35.mov
Adding backgrounds:
adding.background.mov
Multiple backgrounds can be added with an image or a solid color at varying opacity levels. If "Global" is selected, the background always fills the entire view regardless of pan/zoom; otherwise it is scoped to a specified area.
Checklist
[<catalog_entry>] <commit message>naming convention using one of thefollowing
catalog_entryvalues:FEATURE,ENHANCEMENT,BUGFIX,BREAKINGCHANGE,DOC,IGNORE.UI Changes