Search code examples
grafanagrafana-plugin

Grafana Canvas Custom Element Responsiveness


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)


Solution

  • 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:

    1. 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.

    2. 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: []
        }
    }