Skip to content

[FEATURE] Add canvas panel plugin - #729

Open
adrianSepiol wants to merge 2 commits into
perses:mainfrom
adrianSepiol:feature/canvas-panel-plugin
Open

[FEATURE] Add canvas panel plugin#729
adrianSepiol wants to merge 2 commits into
perses:mainfrom
adrianSepiol:feature/canvas-panel-plugin

Conversation

@adrianSepiol

@adrianSepiol adrianSepiol commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Description

Related to: perses/perses#3845

Introduces the canvas panel 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:

Screenshot 2026-07-20 at 15 24 28

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

  • Pull request has a descriptive title and context useful to a reviewer.
  • Pull request title follows the [<catalog_entry>] <commit message> naming convention using one of the
    following catalog_entry values: FEATURE, ENHANCEMENT, BUGFIX, BREAKINGCHANGE, DOC,IGNORE.
  • All commits have DCO signoffs.

UI Changes

  • Changes that impact the UI include screenshots and/or screencasts of the relevant changes.
  • Code follows the UI guidelines.

@adrianSepiol
adrianSepiol force-pushed the feature/canvas-panel-plugin branch 7 times, most recently from 3ea64b7 to 39b9803 Compare July 16, 2026 13:20
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>
@adrianSepiol
adrianSepiol force-pushed the feature/canvas-panel-plugin branch from 39b9803 to d73e5e2 Compare July 17, 2026 07:54
@adrianSepiol
adrianSepiol marked this pull request as ready for review July 20, 2026 13:42
@adrianSepiol
adrianSepiol requested review from a team, AntoineThebaud and Nexucis as code owners July 20, 2026 13:42
@adrianSepiol
adrianSepiol requested review from shahrokni and removed request for a team July 20, 2026 13:42
@@ -0,0 +1,20 @@
{
"kind": "Canvas",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jgbernalp

Copy link
Copy Markdown
Contributor

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?

@adrianSepiol

Copy link
Copy Markdown
Contributor Author

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.

@ibakshay ibakshay left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job Adrian! My agent found some nits.

Comment on lines +213 to +250
{displayNodes.map((node) => {
const onNodePointerDown = (event: PointerEvent<SVGRectElement>): void => {
const unselectedId = selectNode(event, node.id);
if (unselectedId !== null) {
selectItems(new Set([unselectedId]));
} else {
startMove();
}
};
const onNodePointerMove = (event: PointerEvent<SVGRectElement>): void => {
updateMove(event, node.id);
};
const onNodeMouseEnter = (): void => {
if (mode.type !== 'dragging-edge') {
hoverNode(node.id);
}
};
const onNodeMouseLeave = (): void => unhoverNode(node.id);
const onCrossDragStart = (anchor: AnchorPoint, x: number, y: number): void => {
beginEdgeDrag(node.id, anchor, x, y);
startDragEdge();
};
return (
<EditorNode
key={node.id}
node={node}
isHovered={hoveredId === node.id}
isSelected={selectedIds.has(node.id)}
snapTarget={dragEdge?.snapTargetId === node.id}
isDragging={mode.type === 'dragging-edge'}
onPointerDown={onNodePointerDown}
onPointerMove={onNodePointerMove}
onMouseEnter={onNodeMouseEnter}
onMouseLeave={onNodeMouseLeave}
onCrossDragStart={onCrossDragStart}
/>
);
})}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Rule: rerender-no-inline-components — No Inline Components

Inside the displayNodes.map(...) loop (line 213), several inline handler functions are defined (onNodePointerDown, onNodePointerMove, onNodeMouseEnter, onNodeMouseLeave, onCrossDragStart). These create new function references on every render, preventing EditorNode from being memoized.

Fix: Extract a dedicated <EditorNodeWrapper key={node.id} node={node} ... /> component that receives only primitives/stable references. This way, individual nodes only re-render when their own props change. For a canvas with many nodes, this materially impacts performance during pointer-move events.

Comment on lines +252 to +280
{displayEdges.map((edge) => {
const onEdgeClick = (event: PointerEvent<SVGLineElement>): void => {
event.stopPropagation();
selectItems(new Set([edge.id]));
};
const onEndpointPointerDown = (
event: PointerEvent<SVGCircleElement>,
end: 'source' | 'target',
fixedX: number,
fixedY: number,
fixedNodeId: string,
fixedAnchor: AnchorPoint
): void => {
if (beginEndpointDrag(event, edge.id, end, fixedX, fixedY, fixedNodeId, fixedAnchor)) {
startDragEdge();
}
};
return (
<EditorEdge
key={edge.id}
edge={edge}
isSelected={!selectionBoundingBox && selectedIds.has(edge.id)}
isDragging={mode.type === 'dragging-edge'}
nsPrefix={`${NS_PREFIX}-${edge.id}`}
nodeById={nodeById}
onEdgeClick={onEdgeClick}
onEndpointPointerDown={onEndpointPointerDown}
/>
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Rule: rerender-no-inline-components — No Inline Components

Same issue for displayEdges.map(...) (line 252) — inline onEdgeClick and onEndpointPointerDown handlers are created per edge per render.

Fix: Extract a wrapper component that receives stable callbacks and only re-renders when its own edge changes.

/>
</g>

{showLegend && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NOTE] Rule: rendering-conditional-render — Use Ternaries Instead of &&

&& is used for conditional rendering (e.g. {showLegend && (<ThresholdLegend .../>)} at CanvasPanel.tsx:88, {bwd && <EdgeArrowMarker .../>} at EdgeLines.tsx:110). When the left operand is falsy but not null/undefined/false, it can render "0" or "". In this codebase it's safe since the values are booleans/objects, but ternaries are more explicit.

Fix: Replace with ternary, e.g. {showLegend ? <ThresholdLegend ... /> : null}.

(Also applies to canvas/src/components/shared/EdgeLines.tsx:110)

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]));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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]).

Comment on lines +35 to +48
return {
palette: chartsTheme.thresholds.palette,
selection: muiTheme.palette.warning.main,
connection: muiTheme.palette.info.main,
snapHighlight: muiTheme.palette.success.main,
background: muiTheme.palette.background.paper,
divider: muiTheme.palette.divider,
text: muiTheme.palette.text.primary,
labelBackground: muiTheme.palette.background.paper,
labelBorder: muiTheme.palette.divider,
labelText: muiTheme.palette.text.primary,
nodeStroke: muiTheme.palette.background.paper,
nodeDefaultFill: muiTheme.palette.primary.main,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NOTE] Rule: rerender-derived-state-no-effect — Avoid Recreating Objects Each Render

useCanvasTheme (line 32) creates a new object literal on every call (lines 35-48). Since it's used in both EditorNode and EditorEdge (called every frame during drag), this causes unnecessary object allocation.

Fix: Memoize the return value: return useMemo(() => ({ palette: ..., ... }), [muiTheme, chartsTheme]).

@ibakshay

Copy link
Copy Markdown
Contributor

[SUGGESTION] please add a "info" message in the panel for the end users when the canvas is empty. Otherwise, it looks blank. Otherwise, the panel looks blank.
image

Comment thread canvas/sdk/go/canvas.go
Comment on lines +92 to +93
Color string `json:"color,omitempty" yaml:"color,omitempty"`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this hex color code?

Comment thread canvas/sdk/go/canvas.go
Comment on lines +102 to +103
X2 *float64 `json:"x2,omitempty" yaml:"x2,omitempty"`
Y2 *float64 `json:"y2,omitempty" yaml:"y2,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do they have suffix '2'? Is this the edge's middle point?

Comment thread canvas/sdk/go/canvas.go
Comment on lines +116 to +117
X float64 `json:"x" yaml:"x"`
Y float64 `json:"y" yaml:"y"`

@shahrokni shahrokni Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we are repeating 'X' and 'Y' everywhere. Could it have its own struct like Position or Coordinate or it would be too much? Not sure though. Forget it if it is an overkill. Your call.

Comment thread canvas/sdk/go/canvas.go
LabelPosition LabelPosition `json:"labelPosition,omitempty" yaml:"labelPosition,omitempty"`
LabelPadding float64 `json:"labelPadding,omitempty" yaml:"labelPadding,omitempty"`
Icon string `json:"icon,omitempty" yaml:"icon,omitempty"`
Link string `json:"link,omitempty" yaml:"link,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't URL be a more appropriate name?

While a URL (Uniform Resource Locator) is the address of a resource on the web, a Link (or Hyperlink) is an element on the page that will take a user to another page.

https://www.geeksforgeeks.org/computer-networks/difference-between-url-and-link/

@ibakshay

Copy link
Copy Markdown
Contributor

In the edge settings, please add full names for north, south, ... instead of short abbreviations.
image

Comment on lines +114 to +151
<TextField
label="X"
size="small"
type="number"
value={Math.round(background.x)}
onChange={onIntFieldChange('x')}
sx={{ width: 80 }}
disabled={background.global}
/>
<TextField
label="Y"
size="small"
type="number"
value={Math.round(background.y)}
onChange={onIntFieldChange('y')}
sx={{ width: 80 }}
disabled={background.global}
/>
<TextField
label="Width"
size="small"
type="number"
value={Math.round(background.width)}
slotProps={{ htmlInput: { min: 1 } }}
onChange={onIntFieldChange('width', 1)}
sx={{ width: 80 }}
disabled={background.global}
/>
<TextField
label="Height"
size="small"
type="number"
value={Math.round(background.height)}
slotProps={{ htmlInput: { min: 1 } }}
onChange={onIntFieldChange('height', 1)}
sx={{ width: 80 }}
disabled={background.global}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not tested this part yet to understand what it exactly does.
However, my very first impression is that the repetitive part could be shortened using a loop over an array of x, y, height, and width. It seems 90% of these Text fields are identical.

⚠️ I will come back to this part later during the test to understand what it does.

Comment on lines +62 to +66
function parseImageFit(value: string): BackgroundSpec['imageFit'] {
return IMAGE_FIT_OPTIONS.includes(value as BackgroundSpec['imageFit'])
? (value as BackgroundSpec['imageFit'])
: undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems this function guarantees that what is received complies with BackgroundSpec['imageFit'] otherwise it returns undefined.

Checking the rest of the PR I see it has been used in a Select with limited and expected options (Menu Items). So, the question would be why we need this, if it is always dealing with a set of deterministic values? The function would make sense if a free text input was also an option. Am I missing something?

<Select<BackgroundSpec['imageFit']>
            label="Image fit"
            value={background.imageFit ?? 'cover'}
            onChange={(e) => onChange({ ...background, imageFit: parseImageFit(e.target.value ?? '') })}
            MenuProps={{ PaperProps: { style: { maxHeight: 240 } } }}
          >
            <MenuItem value="cover">Cover</MenuItem>
            <MenuItem value="contain">Contain</MenuItem>
            <MenuItem value="stretch">Stretch</MenuItem>
          </Select>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants