Today, I have a dashboard that contains an image that represents the place where some items/devices are located. After adding some custom elements, I have a fixed position, based on the user selection.
However, changing the screen resolution or scale interferes directly in its positioning, just like described in the second figure. Taking this scenario into account, I was not able to find any resource regarding this topic. So, here comes the following doubt: is there any support from Grafana that enables a custom element positioning based on DOM's window
or any similar resource that allows this responsiveness, in order to create a "dynamic" layout?
Thank you in advance for any help or suggestion!
Figure 1 - Custom element in desired position (1920x1080 with 100% scale)
Figure 2 - Custom element in a different position (1920x1080 with 125% scale)
As of Grafana 10.1.1, the canvas panel is still in its nascent stages of development, and unfortunately, direct support for responsiveness hasn't been built in.
However, if you're comfortable with delving into Grafana's open-source codebase, there is a workaround you can consider. You can manually edit the canvas panel plugin and rebuild the frontend. I recommend following Grafana's developer guide for building, which can be found here.
To achieve your desired outcome:
Add Two Inputs to Panel Config:
I introduced two new input fields: Background Width
and Background Height
. These inputs play a crucial role in calculating the relative position and scale. please insert there resolution of your original image in pixels.
Include a Responsiveness Toggle:
I added a boolean switch in the panel config to enable or disable the responsive feature. please note, that I created this functionality for background in Contain
mode, which is in my opinion only one responsive mode.
these are mine results: Figure 1 Figure 2
Below are mine 3 files I edited:
/grafana/public/app/plugins/panel/canvas/CanvasPanel.tsx
import React, { Component } from 'react';
import { ReplaySubject, Subscription } from 'rxjs';
import { PanelProps } from '@grafana/data';
import { locationService } from '@grafana/runtime/src';
import { PanelContext, PanelContextRoot } from '@grafana/ui';
import { CanvasFrameOptions } from 'app/features/canvas';
import { ElementState } from 'app/features/canvas/runtime/element';
import { Scene } from 'app/features/canvas/runtime/scene';
import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
import { SetBackground } from './components/SetBackground';
import { InlineEdit } from './editor/inline/InlineEdit';
import { Options } from './panelcfg.gen';
import { AnchorPoint, CanvasTooltipPayload, ConnectionState } from './types';
interface Props extends PanelProps<Options> {}
interface State {
refresh: number;
openInlineEdit: boolean;
openSetBackground: boolean;
contextMenuAnchorPoint: AnchorPoint;
moveableAction: boolean;
}
export interface InstanceState {
scene: Scene;
selected: ElementState[];
selectedConnection?: ConnectionState;
}
export interface SelectionAction {
panel: CanvasPanel;
}
let canvasInstances: CanvasPanel[] = [];
let activeCanvasPanel: CanvasPanel | undefined = undefined;
let isInlineEditOpen = false;
let isSetBackgroundOpen = false;
export const activePanelSubject = new ReplaySubject<SelectionAction>(1);
export class CanvasPanel extends Component<Props, State> {
declare context: React.ContextType<typeof PanelContextRoot>;
static contextType = PanelContextRoot;
panelContext: PanelContext | undefined;
readonly scene: Scene;
private subs = new Subscription();
needsReload = false;
isEditing = locationService.getSearchObject().editPanel !== undefined;
//added
declare backgroundTrueWidth: number;
declare backgroundTrueHeight: number;
constructor(props: Props) {
super(props);
this.state = {
refresh: 0,
openInlineEdit: false,
openSetBackground: false,
contextMenuAnchorPoint: { x: 0, y: 0 },
moveableAction: false,
};
// Only the initial options are ever used.
// later changes are all controlled by the scene
this.scene = new Scene(
this.props.options.root,
this.props.options.inlineEditing,
this.props.options.showAdvancedTypes,
this.onUpdateScene,
this
);
this.scene.updateSize(props.width, props.height);
this.scene.updateData(props.data);
this.scene.inlineEditingCallback = this.openInlineEdit;
this.scene.setBackgroundCallback = this.openSetBackground;
this.scene.tooltipCallback = this.tooltipCallback;
this.scene.moveableActionCallback = this.moveableActionCallback;
this.subs.add(
this.props.eventBus.subscribe(PanelEditEnteredEvent, (evt: PanelEditEnteredEvent) => {
// Remove current selection when entering edit mode for any panel in dashboard
this.scene.clearCurrentSelection();
this.closeInlineEdit();
})
);
this.subs.add(
this.props.eventBus.subscribe(PanelEditExitedEvent, (evt: PanelEditExitedEvent) => {
if (this.props.id === evt.payload) {
this.needsReload = true;
this.scene.clearCurrentSelection();
}
})
);
}
//added
calculateBackgroundSize(){
let panelRatio = this.props.width / this.props.height;
let backgroundRatio = this.props.options.backgroundWidth / this.props.options.backgroundHeight;
if(backgroundRatio>=0){
if(panelRatio>backgroundRatio){
this.backgroundTrueHeight = this.props.height;
this.backgroundTrueWidth = this.props.height * backgroundRatio;
}else{
this.backgroundTrueWidth = this.props.width;
this.backgroundTrueHeight = this.props.width / backgroundRatio;
}
}else{
if(panelRatio>backgroundRatio){
this.backgroundTrueHeight = this.props.height;
this.backgroundTrueWidth = this.props.height / backgroundRatio;
}else{
this.backgroundTrueWidth = this.props.width;
this.backgroundTrueHeight = this.props.width * backgroundRatio;
}
}
return;
};
//added
calculateRelativePosition(xDisplayed: number, yDisplayed: number): { xOriginal: number, yOriginal: number }{
let widthRatioPos = this.props.options.backgroundWidth / this.backgroundTrueWidth;
let heightRatioPos = this.props.options.backgroundHeight / this.backgroundTrueHeight;
let xOriginal = xDisplayed * widthRatioPos;
let yOriginal = yDisplayed * heightRatioPos;
return {xOriginal, yOriginal};
}
//added
calculateRelativeScale(xDisplayed: number, yDisplayed: number): { xOriginal: number, yOriginal: number }{
let widthRatioScale = this.props.options.backgroundWidth / this.backgroundTrueWidth;
let heightRatioScale = this.props.options.backgroundHeight / this.backgroundTrueHeight;
let xOriginal = xDisplayed * widthRatioScale;
let yOriginal = yDisplayed * heightRatioScale;
return {xOriginal, yOriginal};
}
//added
calculateNewPosition(xRelativePos: number, yRelativePos: number, originalWidth: number, originalHeight: number, newWidth: number, newHeight: number): { xNew: number, yNew: number } {
let xNew = (newWidth / originalWidth) * xRelativePos;
let yNew = (newHeight / originalHeight) * yRelativePos;
return { xNew, yNew };
}
//added
calculateNewScale(xRelativeScale: number, yRelativeScale: number, originalWidth: number, originalHeight: number, newWidth: number, newHeight: number): { xNew: number, yNew: number } {
let xNew = (newWidth / originalWidth) * xRelativeScale;
let yNew = (newHeight / originalHeight) * yRelativeScale;
return { xNew, yNew };
}
componentDidMount() {
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
this.panelContext = this.context;
if (this.panelContext.onInstanceStateChange) {
this.panelContext.onInstanceStateChange({
scene: this.scene,
layer: this.scene.root,
});
this.subs.add(
this.scene.selection.subscribe({
next: (v) => {
if (v.length) {
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
}
canvasInstances.forEach((canvasInstance) => {
if (canvasInstance !== activeCanvasPanel) {
canvasInstance.scene.clearCurrentSelection(true);
canvasInstance.scene.connections.select(undefined);
}
});
this.panelContext?.onInstanceStateChange!({
scene: this.scene,
selected: v,
layer: this.scene.root,
});
},
})
);
this.subs.add(
this.scene.connections.selection.subscribe({
next: (v) => {
if (!this.context.instanceState) {
return;
}
this.panelContext?.onInstanceStateChange!({
scene: this.scene,
selected: this.context.instanceState.selected,
selectedConnection: v,
layer: this.scene.root,
});
if (v) {
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
}
canvasInstances.forEach((canvasInstance) => {
if (canvasInstance !== activeCanvasPanel) {
canvasInstance.scene.clearCurrentSelection(true);
canvasInstance.scene.connections.select(undefined);
}
});
setTimeout(() => {
this.forceUpdate();
});
},
})
);
}
//added
if(this?.props?.options?.isResponsive === true){
this.calculateBackgroundSize();
let newWidth = this?.backgroundTrueWidth;
let newHeight = this?.backgroundTrueHeight;
let originalWidth = this?.props?.options?.backgroundWidth;
let originalHeight = this?.props?.options?.backgroundHeight;
let elements = this?.props?.options?.root?.elements;
//calculate now positions of elements
elements.forEach(element => {
if (element.placement && typeof element.placement.xRelativePos === 'number' && typeof element.placement.yRelativePos === 'number') {
let newPosition = this.calculateNewPosition(element.placement.xRelativePos, element.placement.yRelativePos, originalWidth, originalHeight, newWidth, newHeight);
element.placement.left = newPosition.xNew;
element.placement.top = newPosition.yNew;
}
});
//calculate now scale of elements
elements.forEach(element => {
if (element.placement && typeof element.placement.xRelativeScale === 'number' && typeof element.placement.yRelativeScale === 'number') {
let newPosition = this.calculateNewScale(element.placement.xRelativeScale, element.placement.yRelativeScale, originalWidth, originalHeight, newWidth, newHeight);
element.placement.width = newPosition.xNew;
element.placement.height = newPosition.yNew;
}
});
this.scene.updateData(this?.props?.data);
this.scene.updateSize(this?.props?.width, this?.props?.height);
}
canvasInstances.push(this);
}
componentWillUnmount() {
this.scene.subscription.unsubscribe();
this.subs.unsubscribe();
isInlineEditOpen = false;
isSetBackgroundOpen = false;
canvasInstances = canvasInstances.filter((ci) => ci.props.id !== activeCanvasPanel?.props.id);
}
// NOTE, all changes to the scene flow through this function
// even the editor gets current state from the same scene instance!
onUpdateScene = (root: CanvasFrameOptions) => {
const { onOptionsChange, options } = this.props;
onOptionsChange({
...options,
root,
});
this.setState({ refresh: this.state.refresh + 1 });
activePanelSubject.next({ panel: this });
};
shouldComponentUpdate(nextProps: Props, nextState: State ) {
const { width, height, data, options } = this.props;
let changed = false;
if (width !== nextProps.width || height !== nextProps.height) {
//added
if(this?.props?.options?.isResponsive === true){
this.calculateBackgroundSize();
//console.log("panel changed!");
let newWidth = this?.backgroundTrueWidth;
let newHeight = this?.backgroundTrueHeight;
let originalWidth = this?.props?.options?.backgroundWidth;
let originalHeight = this?.props?.options?.backgroundHeight;
let elements = nextProps?.options?.root?.elements;
//calculate now positions of elements
elements.forEach(element => {
if (element.placement && typeof element.placement.xRelativePos === 'number' && typeof element.placement.yRelativePos === 'number') {
let newPosition = this.calculateNewPosition(element.placement.xRelativePos, element.placement.yRelativePos, originalWidth, originalHeight, newWidth, newHeight);
element.placement.left = newPosition.xNew;
element.placement.top = newPosition.yNew;
}
});
//calculate now scale of elements
elements.forEach(element => {
if (element.placement && typeof element.placement.xRelativeScale === 'number' && typeof element.placement.yRelativeScale === 'number') {
let newPosition = this.calculateNewScale(element.placement.xRelativeScale, element.placement.yRelativeScale, originalWidth, originalHeight, newWidth, newHeight);
element.placement.width = newPosition.xNew;
element.placement.height = newPosition.yNew;
}
});
}
this.scene.updateData(nextProps.data);
this.scene.updateSize(nextProps.width, nextProps.height);
changed = true;
}
if (data !== nextProps.data && !this.scene.ignoreDataUpdate) {
this.scene.updateData(nextProps.data);
changed = true;
}
if (options !== nextProps.options && !this.scene.ignoreDataUpdate) {
this.scene.updateData(nextProps.data);
changed = true;
//added
if(this?.props?.options?.isResponsive === true){
this.calculateBackgroundSize();
let elements = nextProps?.options?.root?.elements;
if (elements && elements.length) {
elements.forEach(element => {
//calculating relative position of elements
if (typeof element?.placement?.left === 'number' && typeof element?.placement?.top === 'number') {
let relativePos = this.calculateRelativePosition(element.placement.left, element.placement.top);
element.placement.xRelativePos = relativePos.xOriginal;
element.placement.yRelativePos = relativePos.yOriginal;
}
//calculating relative scale of elements
if (typeof element?.placement?.width === 'number' && typeof element?.placement?.height === 'number') {
let relativeScale = this.calculateRelativeScale(element.placement.width, element.placement.height);
element.placement.xRelativeScale = relativeScale.xOriginal;
element.placement.yRelativeScale = relativeScale.yOriginal;
}
});
// Here elements array has updated positions and scale.
}
}
}
if (this.state.refresh !== nextState.refresh) {
changed = true;
}
if (this.state.openInlineEdit !== nextState.openInlineEdit) {
changed = true;
}
if (this.state.openSetBackground !== nextState.openSetBackground) {
changed = true;
}
if (this.state.moveableAction !== nextState.moveableAction) {
changed = true;
}
// After editing, the options are valid, but the scene was in a different panel or inline editing mode has changed
const inlineEditingSwitched = this.props.options.inlineEditing !== nextProps.options.inlineEditing;
const shouldShowAdvancedTypesSwitched =
this.props.options.showAdvancedTypes !== nextProps.options.showAdvancedTypes;
if (this.needsReload || inlineEditingSwitched || shouldShowAdvancedTypesSwitched) {
if (inlineEditingSwitched) {
// Replace scene div to prevent selecto instance leaks
this.scene.revId++;
}
this.needsReload = false;
this.scene.load(nextProps.options.root, nextProps.options.inlineEditing, nextProps.options.showAdvancedTypes);
this.scene.updateSize(nextProps.width, nextProps.height);
this.scene.updateData(nextProps.data);
changed = true;
}
return changed;
}
openInlineEdit = () => {
if (isInlineEditOpen) {
this.forceUpdate();
this.setActivePanel();
return;
}
this.setActivePanel();
this.setState({ openInlineEdit: true });
isInlineEditOpen = true;
};
openSetBackground = (anchorPoint: AnchorPoint) => {
if (isSetBackgroundOpen) {
this.forceUpdate();
this.setActivePanel();
return;
}
this.setActivePanel();
this.setState({ openSetBackground: true });
this.setState({ contextMenuAnchorPoint: anchorPoint });
isSetBackgroundOpen = true;
};
tooltipCallback = (tooltip: CanvasTooltipPayload | undefined) => {
this.scene.tooltip = tooltip;
this.forceUpdate();
};
moveableActionCallback = (updated: boolean) => {
this.setState({ moveableAction: updated });
this.forceUpdate();
};
closeInlineEdit = () => {
this.setState({ openInlineEdit: false });
isInlineEditOpen = false;
};
closeSetBackground = () => {
this.setState({ openSetBackground: false });
isSetBackgroundOpen = false;
};
setActivePanel = () => {
activeCanvasPanel = this;
activePanelSubject.next({ panel: this });
};
renderInlineEdit = () => {
return <InlineEdit onClose={() => this.closeInlineEdit()} id={this.props.id} scene={this.scene} />;
};
renderSetBackground = () => {
return (
<SetBackground
onClose={() => this.closeSetBackground()}
scene={this.scene}
anchorPoint={this.state.contextMenuAnchorPoint}
/>
);
};
render() {
return (
<>
{this.scene.render()}
{this.state.openInlineEdit && this.renderInlineEdit()}
{this.state.openSetBackground && this.renderSetBackground()}
</>
);
}
}
/grafana/public/app/plugins/panel/canvas/module.tsx
import { FieldConfigProperty, PanelOptionsEditorBuilder, PanelPlugin } from '@grafana/data';
import { FrameState } from 'app/features/canvas/runtime/frame';
import { CanvasPanel, InstanceState } from './CanvasPanel';
import { getConnectionEditor } from './editor/connectionEditor';
import { getElementEditor } from './editor/element/elementEditor';
import { getLayerEditor } from './editor/layer/layerEditor';
import { canvasMigrationHandler } from './migrations';
import { Options } from './panelcfg.gen';
export const addStandardCanvasEditorOptions = (builder: PanelOptionsEditorBuilder<Options>) => {
builder.addBooleanSwitch({
path: 'inlineEditing',
name: 'Inline editing',
description: 'Enable editing the panel directly',
defaultValue: true,
});
builder.addBooleanSwitch({
path: 'showAdvancedTypes',
name: 'Experimental element types',
description: 'Enable selection of experimental element types',
defaultValue: true,
});
//added
builder.addBooleanSwitch({
path: 'isResponsive',
name: 'Responsive elements',
description: 'Set true if you want to elements have responsive position and scale based on backgroung',
defaultValue: false,
});
//added
builder.addNumberInput({
path: 'backgroundWidth',
name: 'Background width',
description: 'Width of original backgronud picture in pixels',
defaultValue: 800,
});
//added
builder.addNumberInput({
path: 'backgroundHeight',
name: 'Background height',
description: 'Height of original backgronud picture in pixels',
defaultValue: 600,
});
};
export const plugin = new PanelPlugin<Options>(CanvasPanel)
.setNoPadding() // extend to panel edges
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Mappings]: {
settings: {
icon: true,
},
},
},
})
.setMigrationHandler(canvasMigrationHandler)
.setPanelOptions((builder, context) => {
const state: InstanceState = context.instanceState;
addStandardCanvasEditorOptions(builder);
if (state) {
builder.addNestedOptions(getLayerEditor(state));
const selection = state.selected;
const connectionSelection = state.selectedConnection;
if (selection?.length === 1) {
const element = selection[0];
if (!(element instanceof FrameState)) {
builder.addNestedOptions(
getElementEditor({
category: [`Selected element (${element.options.name})`],
element,
scene: state.scene,
})
);
}
}
if (connectionSelection) {
builder.addNestedOptions(
getConnectionEditor({
category: ['Selected connection'],
connection: connectionSelection,
scene: state.scene,
})
);
}
}
});
/grafana/public/app/plugins/panel/canvas/panelcfg.cue
// Copyright 2023 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaplugin
import (
ui "github.com/grafana/grafana/packages/grafana-schema/src/common"
)
composableKinds: PanelCfg: {
maturity: "experimental"
lineage: {
schemas: [{
version: [0, 0]
schema: {
HorizontalConstraint: "left" | "right" | "leftright" | "center" | "scale" @cuetsy(kind="enum", memberNames="Left|Right|LeftRight|Center|Scale")
VerticalConstraint: "top" | "bottom" | "topbottom" | "center" | "scale" @cuetsy(kind="enum", memberNames="Top|Bottom|TopBottom|Center|Scale")
Constraint: {
horizontal?: HorizontalConstraint
vertical?: VerticalConstraint
} @cuetsy(kind="interface")
Placement: {
top?: float64
left?: float64
right?: float64
bottom?: float64
width?: float64
height?: float64
xRelativePos?: float64
yRelativePos?: float64
xRelativeScale?: float64
yRelativeScale?: float64
} @cuetsy(kind="interface")
BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile")
BackgroundConfig: {
color?: ui.ColorDimensionConfig
image?: ui.ResourceDimensionConfig
size?: BackgroundImageSize
} @cuetsy(kind="interface")
BackgroundTrueSizeInPixels: {
width?: float64
height?: float64
} @cuetsy(kind="interface")
LineConfig: {
color?: ui.ColorDimensionConfig
width?: float64
} @cuetsy(kind="interface")
ConnectionCoordinates: {
x: float64
y: float64
} @cuetsy(kind="interface")
ConnectionPath: "straight" @cuetsy(kind="enum", memberNames="Straight")
CanvasConnection: {
source: ConnectionCoordinates
target: ConnectionCoordinates
targetName?: string
path: ConnectionPath
color?: ui.ColorDimensionConfig
size?: ui.ScaleDimensionConfig
} @cuetsy(kind="interface")
CanvasElementOptions: {
name: string
type: string
// TODO: figure out how to define this (element config(s))
config?: _
constraint?: Constraint
placement?: Placement
background?: BackgroundConfig
border?: LineConfig
connections?: [...CanvasConnection]
} @cuetsy(kind="interface")
Options: {
// Enable inline editing
inlineEditing: bool | *true
// Show all available element types
showAdvancedTypes: bool | *true
// The root element of canvas (frame), where all canvas elements are nested
// TODO: Figure out how to define a default value for this
root: {
// Name of the root element
name: string
// Type of root element (frame)
type: "frame"
// The list of canvas elements attached to the root element
elements: [...CanvasElementOptions]
} @cuetsy(kind="interface")
// Added properties for responsiveness
backgroundHeight: float64
backgroundWidth: float64
isResponsive: bool | *false
} @cuetsy(kind="interface")
}
}]
lenses: []
}
}