Grafana Canvas Custom Element Responsiveness

490 Views Asked by At

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)

1

There are 1 best solutions below

0
Tomáš Palovský On BEST ANSWER

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