diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts new file mode 100644 index 00000000000..c2683858800 --- /dev/null +++ b/public/app/features/canvas/element.ts @@ -0,0 +1,56 @@ +import { ComponentType } from 'react'; +import { PanelOptionsEditorBuilder, RegistryItem } from '@grafana/data'; +import { Anchor, BackgroundConfig, LineConfig, Placement } from './types'; +import { DimensionContext } from '../dimensions/context'; + +/** + * This gets saved in panel json + * + * depending on the type, it may have additional config + * + * @alpha + */ +export interface CanvasElementOptions { + type: string; + + // Custom options depending on the type + config?: TConfig; + + // Standard options avaliable for all elements + anchor?: Anchor; // defaults top, left, width and height + placement?: Placement; + background?: BackgroundConfig; + border?: LineConfig; +} + +export interface CanvasElementProps { + // Saved config + config: TConfig; + + // Calculated position info + width: number; + height: number; + + // Raw data + data?: TData; +} + +/** + * Canvas item builder + * + * @alpha + */ +export interface CanvasElementItem extends RegistryItem { + /** The default width/height to use when adding */ + defaultSize?: Placement; + + defaultConfig: TConfig; + + prepareData?: (ctx: DimensionContext, cfg: TConfig) => TData; + + /** Component used to draw */ + display: ComponentType>; + + /** Build the configuraiton UI */ + registerOptionsUI?: (builder: PanelOptionsEditorBuilder>) => void; +} diff --git a/public/app/features/canvas/elements/icon.tsx b/public/app/features/canvas/elements/icon.tsx new file mode 100644 index 00000000000..e5a1c279359 --- /dev/null +++ b/public/app/features/canvas/elements/icon.tsx @@ -0,0 +1,149 @@ +import React, { CSSProperties } from 'react'; + +import { CanvasElementItem, CanvasElementProps } from '../element'; +import { ColorDimensionConfig, ResourceDimensionConfig, ResourceDimensionMode } from 'app/features/dimensions'; +import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors'; +import SVG from 'react-inlinesvg'; +import { css } from '@emotion/css'; +import { isString } from 'lodash'; +import { LineConfig } from '../types'; +import { DimensionContext } from 'app/features/dimensions/context'; + +interface IconConfig { + path?: ResourceDimensionConfig; + fill?: ColorDimensionConfig; + stroke?: LineConfig; +} + +interface IconData { + path: string; + fill: string; + strokeColor?: string; + stroke?: number; +} + +// When a stoke is defined, we want the path to be in page units +const svgStrokePathClass = css` + path { + vector-effect: non-scaling-stroke; + } +`; + +export function IconDisplay(props: CanvasElementProps) { + const { width, height, data } = props; + if (!data?.path) { + return null; + } + + const svgStyle: CSSProperties = { + fill: data?.fill, + stroke: data?.strokeColor, + strokeWidth: data?.stroke, + }; + + return ( + + ); +} + +export const iconItem: CanvasElementItem = { + id: 'icon', + name: 'Icon', + description: 'SVG Icon display', + + display: IconDisplay, + + defaultConfig: { + path: { + mode: ResourceDimensionMode.Fixed, + fixed: 'question-circle.svg', + }, + fill: { fixed: '#FFF899' }, + }, + + defaultSize: { + width: 50, + height: 50, + }, + + // Called when data changes + prepareData: (ctx: DimensionContext, cfg: IconConfig) => { + const iconRoot = (window as any).__grafana_public_path__ + 'img/icons/unicons/'; + let path: string | undefined = undefined; + if (cfg.path) { + path = ctx.getResource(cfg.path).value(); + } + if (!path || !isString(path)) { + // must be something? + path = 'question-circle.svg'; + } + if (path.indexOf(':/') < 0) { + path = iconRoot + path; + } + + const data: IconData = { + path, + fill: cfg.fill ? ctx.getColor(cfg.fill).value() : '#CCC', + }; + + if (cfg.stroke?.width && cfg.stroke.color) { + if (cfg.stroke.width > 0) { + data.stroke = cfg.stroke?.width; + data.strokeColor = ctx.getColor(cfg.stroke.color).value(); + } + } + return data; + }, + + // Heatmap overlay options + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'iconSelector', + path: 'config.path', + name: 'SVG Path', + editor: ResourceDimensionEditor, + settings: { + resourceType: 'icon', + }, + }) + .addCustomEditor({ + id: 'config.fill', + path: 'config.fill', + name: 'Icon fill color', + editor: ColorDimensionEditor, + settings: {}, + defaultValue: { + // Configured values + fixed: 'grey', + }, + }) + .addSliderInput({ + path: 'config.stroke.width', + name: 'Stroke', + defaultValue: 0, + settings: { + min: 0, + max: 10, + }, + }) + .addCustomEditor({ + id: 'config.stroke.color', + path: 'config.stroke.color', + name: 'Icon Stroke color', + editor: ColorDimensionEditor, + settings: {}, + defaultValue: { + // Configured values + fixed: 'grey', + }, + showIf: (cfg) => Boolean(cfg.config?.stroke?.width), + }); + }, +}; diff --git a/public/app/features/canvas/elements/notFound.tsx b/public/app/features/canvas/elements/notFound.tsx new file mode 100644 index 00000000000..83e78bf388e --- /dev/null +++ b/public/app/features/canvas/elements/notFound.tsx @@ -0,0 +1,34 @@ +import React, { PureComponent } from 'react'; + +import { CanvasElementItem, CanvasElementProps } from '../element'; + +interface NotFoundConfig { + orig?: any; +} + +class NotFoundDisplay extends PureComponent> { + render() { + const { config } = this.props; + return ( +
+

NOT FOUND:

+
{JSON.stringify(config, null, 2)}
+
+ ); + } +} + +export const notFoundItem: CanvasElementItem = { + id: 'not-found', + name: 'Not found', + description: 'Display when element type is not found in the registry', + + defaultConfig: {}, + + display: NotFoundDisplay, + + defaultSize: { + width: 100, + height: 100, + }, +}; diff --git a/public/app/features/canvas/elements/textBox.tsx b/public/app/features/canvas/elements/textBox.tsx new file mode 100644 index 00000000000..6436ff7d5e1 --- /dev/null +++ b/public/app/features/canvas/elements/textBox.tsx @@ -0,0 +1,147 @@ +import React, { PureComponent } from 'react'; +import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor'; +import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor'; +import { ColorDimensionConfig, TextDimensionConfig } from 'app/features/dimensions/types'; + +import { CanvasElementItem, CanvasElementProps } from '../element'; +import { css } from '@emotion/css'; +import { stylesFactory } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from 'app/core/config'; +import { DimensionContext } from 'app/features/dimensions/context'; + +export enum Align { + Left = 'left', + Center = 'center', + Right = 'right', +} + +export enum VAlign { + Top = 'top', + Middle = 'middle', + Bottom = 'bottom', +} + +interface TextBoxData { + text?: string; + color?: string; + size?: number; // 0 or missing will "auto size" + align: Align; + valign: VAlign; +} + +interface TextBoxConfig { + text?: TextDimensionConfig; + color?: ColorDimensionConfig; + size?: number; // 0 or missing will "auto size" + align: Align; + valign: VAlign; +} + +class TextBoxDisplay extends PureComponent> { + render() { + const { data } = this.props; + const styles = getStyles(config.theme2, data); + return ( +
+ {data?.text} +
+ ); + } +} +const getStyles = stylesFactory((theme: GrafanaTheme2, data) => ({ + container: css` + position: absolute; + height: 100%; + width: 100%; + display: table; + `, + span: css` + display: table-cell; + vertical-align: ${data.valign}; + text-align: ${data.align}; + font-size: ${data?.size}px; + color: ${data?.color}; + `, +})); +export const textBoxItem: CanvasElementItem = { + id: 'text-box', + name: 'Text', + description: 'Text box', + + display: TextBoxDisplay, + + defaultConfig: { + align: Align.Left, + valign: VAlign.Middle, + }, + + defaultSize: { + width: 240, + height: 160, + }, + + // Called when data changes + prepareData: (ctx: DimensionContext, cfg: TextBoxConfig) => { + const data: TextBoxData = { + text: cfg.text ? ctx.getText(cfg.text).value() : '', + align: cfg.align ?? Align.Center, + valign: cfg.valign ?? VAlign.Middle, + size: cfg.size, + }; + if (cfg.color) { + data.color = ctx.getColor(cfg.color).value(); + } + return data; + }, + + // Heatmap overlay options + registerOptionsUI: (builder) => { + builder + .addCustomEditor({ + id: 'textSelector', + path: 'config.text', + name: 'Text', + editor: TextDimensionEditor, + }) + .addCustomEditor({ + id: 'config.color', + path: 'config.color', + name: 'Text color', + editor: ColorDimensionEditor, + settings: {}, + defaultValue: {}, + }) + .addRadio({ + path: 'config.align', + name: 'Align text', + settings: { + options: [ + { value: Align.Left, label: 'Left' }, + { value: Align.Center, label: 'Center' }, + { value: Align.Right, label: 'Right' }, + ], + }, + defaultValue: Align.Left, + }) + .addRadio({ + path: 'config.valign', + name: 'Vertical align', + settings: { + options: [ + { value: VAlign.Top, label: 'Top' }, + { value: VAlign.Middle, label: 'Middle' }, + { value: VAlign.Bottom, label: 'Bottom' }, + ], + }, + defaultValue: VAlign.Middle, + }) + .addNumberInput({ + path: 'config.size', + name: 'Text size', + settings: { + placeholder: 'Auto', + }, + }); + }, +}; diff --git a/public/app/features/canvas/group.ts b/public/app/features/canvas/group.ts new file mode 100644 index 00000000000..e456a4069e3 --- /dev/null +++ b/public/app/features/canvas/group.ts @@ -0,0 +1,7 @@ +import { CanvasElementOptions } from './element'; + +export interface CanvasGroupOptions extends CanvasElementOptions { + type: 'group'; + elements: CanvasElementOptions[]; + // layout? // absolute, list, grid? +} diff --git a/public/app/features/canvas/index.ts b/public/app/features/canvas/index.ts new file mode 100644 index 00000000000..1bce0d41f59 --- /dev/null +++ b/public/app/features/canvas/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './element'; +export { CanvasGroupOptions } from './group'; +export * from './registry'; diff --git a/public/app/features/canvas/registry.ts b/public/app/features/canvas/registry.ts new file mode 100644 index 00000000000..8046aac9046 --- /dev/null +++ b/public/app/features/canvas/registry.ts @@ -0,0 +1,15 @@ +import { Registry } from '@grafana/data'; +import { CanvasElementItem, CanvasElementOptions } from './element'; +import { iconItem } from './elements/icon'; +import { textBoxItem } from './elements/textBox'; + +export const DEFAULT_CANVAS_ELEMENT_CONFIG: CanvasElementOptions = { + type: iconItem.id, + config: { ...iconItem.defaultConfig }, + placement: { ...iconItem.defaultSize }, +}; + +export const canvasElementRegistry = new Registry(() => [ + iconItem, // default for now + textBoxItem, +]); diff --git a/public/app/features/canvas/runtime/element.tsx b/public/app/features/canvas/runtime/element.tsx new file mode 100644 index 00000000000..706a56f5f8d --- /dev/null +++ b/public/app/features/canvas/runtime/element.tsx @@ -0,0 +1,137 @@ +import React, { CSSProperties } from 'react'; +import { + BackgroundImageSize, + CanvasElementItem, + CanvasElementOptions, + canvasElementRegistry, +} from 'app/features/canvas'; +import { DimensionContext } from 'app/features/dimensions'; +import { notFoundItem } from 'app/features/canvas/elements/notFound'; +import { GroupState } from './group'; + +let counter = 100; + +export class ElementState { + readonly UID = counter++; + + revId = 0; + style: CSSProperties = {}; + + // Calculated + width = 100; + height = 100; + data?: any; // depends on the type + + constructor(public item: CanvasElementItem, public options: CanvasElementOptions, public parent?: GroupState) { + if (!options) { + this.options = { type: item.id }; + } + } + + // The parent size, need to set our own size based on offsets + updateSize(width: number, height: number) { + this.width = width; + this.height = height; + + // Update the CSS position + this.style = { + ...this.style, + width, + height, + }; + } + + updateData(ctx: DimensionContext) { + if (this.item.prepareData) { + this.data = this.item.prepareData(ctx, this.options.config); + this.revId++; // rerender + } + + const { background, border } = this.options; + const css: CSSProperties = {}; + if (background) { + if (background.color) { + const color = ctx.getColor(background.color); + css.backgroundColor = color.value(); + } + if (background.image) { + const image = ctx.getResource(background.image); + if (image) { + const v = image.value(); + if (v) { + css.backgroundImage = `url("${v}")`; + switch (background.size ?? BackgroundImageSize.Contain) { + case BackgroundImageSize.Contain: + css.backgroundSize = 'contain'; + css.backgroundRepeat = 'no-repeat'; + break; + case BackgroundImageSize.Cover: + css.backgroundSize = 'cover'; + css.backgroundRepeat = 'no-repeat'; + break; + case BackgroundImageSize.Original: + css.backgroundRepeat = 'no-repeat'; + break; + case BackgroundImageSize.Tile: + css.backgroundRepeat = 'repeat'; + break; + case BackgroundImageSize.Fill: + css.backgroundSize = '100% 100%'; + break; + } + } + } + } + } + + if (border && border.color && border.width) { + const color = ctx.getColor(border.color); + css.borderWidth = border.width; + css.borderStyle = 'solid'; + css.borderColor = color.value(); + + // Move the image to inside the border + if (css.backgroundImage) { + css.backgroundOrigin = 'padding-box'; + } + } + + css.width = this.width; + css.height = this.height; + + this.style = css; + } + + /** Recursivly visit all nodes */ + visit(visitor: (v: ElementState) => void) { + visitor(this); + } + + // Something changed + onChange(options: CanvasElementOptions) { + if (this.item.id !== options.type) { + this.item = canvasElementRegistry.getIfExists(options.type) ?? notFoundItem; + } + + this.revId++; + this.options = { ...options }; + let trav = this.parent; + while (trav) { + trav.revId++; + trav = trav.parent; + } + } + + getSaveModel() { + return { ...this.options }; + } + + render() { + const { item } = this; + return ( +
+ +
+ ); + } +} diff --git a/public/app/features/canvas/runtime/group.tsx b/public/app/features/canvas/runtime/group.tsx new file mode 100644 index 00000000000..5b109e47c92 --- /dev/null +++ b/public/app/features/canvas/runtime/group.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { CanvasGroupOptions, canvasElementRegistry } from 'app/features/canvas'; +import { DimensionContext } from 'app/features/dimensions'; +import { notFoundItem } from 'app/features/canvas/elements/notFound'; +import { ElementState } from './element'; +import { CanvasElementItem } from '../element'; + +export const groupItemDummy: CanvasElementItem = { + id: 'group', + name: 'Group', + description: 'Group', + + defaultConfig: {}, + + // eslint-disable-next-line react/display-name + display: () => { + return
GROUP!
; + }, +}; + +export class GroupState extends ElementState { + readonly elements: ElementState[] = []; + + constructor(public options: CanvasGroupOptions, public parent?: GroupState) { + super(groupItemDummy, options, parent); + + // mutate options object + let { elements } = this.options; + if (!elements) { + this.options.elements = elements = []; + } + + for (const c of elements) { + if (c.type === 'group') { + this.elements.push(new GroupState(c as CanvasGroupOptions, this)); + } else { + const item = canvasElementRegistry.getIfExists(c.type) ?? notFoundItem; + this.elements.push(new ElementState(item, c, parent)); + } + } + } + + // The parent size, need to set our own size based on offsets + updateSize(width: number, height: number) { + super.updateSize(width, height); + + // Update children with calculated size + for (const elem of this.elements) { + elem.updateSize(this.width, this.height); + } + } + + updateData(ctx: DimensionContext) { + super.updateData(ctx); + for (const elem of this.elements) { + elem.updateData(ctx); + } + } + + render() { + return ( +
+ {this.elements.map((v) => v.render())} +
+ ); + } + + /** Recursivly visit all nodes */ + visit(visitor: (v: ElementState) => void) { + super.visit(visitor); + for (const e of this.elements) { + visitor(e); + } + } + + getSaveModel() { + return { + ...this.options, + elements: this.elements.map((v) => v.getSaveModel()), + }; + } +} diff --git a/public/app/features/canvas/runtime/scene.tsx b/public/app/features/canvas/runtime/scene.tsx new file mode 100644 index 00000000000..db5cdde330a --- /dev/null +++ b/public/app/features/canvas/runtime/scene.tsx @@ -0,0 +1,116 @@ +import React, { CSSProperties } from 'react'; +import { css } from '@emotion/css'; +import { config } from 'app/core/config'; +import { GrafanaTheme2, PanelData } from '@grafana/data'; +import { stylesFactory } from '@grafana/ui'; +import { CanvasElementOptions, CanvasGroupOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas'; +import { + ColorDimensionConfig, + ResourceDimensionConfig, + ScaleDimensionConfig, + TextDimensionConfig, + DimensionContext, +} from 'app/features/dimensions'; +import { + getColorDimensionFromData, + getScaleDimensionFromData, + getResourceDimensionFromData, + getTextDimensionFromData, +} from 'app/features/dimensions/utils'; +import { ReplaySubject } from 'rxjs'; +import { GroupState } from './group'; +import { ElementState } from './element'; + +export class Scene { + private root: GroupState; + private lookup = new Map(); + styles = getStyles(config.theme2); + readonly selected = new ReplaySubject(undefined); + revId = 0; + + width = 0; + height = 0; + style: CSSProperties = {}; + data?: PanelData; + + constructor(cfg: CanvasGroupOptions, public onSave: (cfg: CanvasGroupOptions) => void) { + this.root = this.load(cfg); + } + + load(cfg: CanvasGroupOptions) { + console.log('LOAD', cfg, this); + this.root = new GroupState( + cfg ?? { + type: 'group', + elements: [DEFAULT_CANVAS_ELEMENT_CONFIG], + } + ); + + // Build the scene registry + this.lookup.clear(); + this.root.visit((v) => { + this.lookup.set(v.UID, v); + + // HACK! select the first/only item + if (v.item.id !== 'group') { + this.selected.next(v); + } + }); + return this.root; + } + + context: DimensionContext = { + getColor: (color: ColorDimensionConfig) => getColorDimensionFromData(this.data, color), + getScale: (scale: ScaleDimensionConfig) => getScaleDimensionFromData(this.data, scale), + getText: (text: TextDimensionConfig) => getTextDimensionFromData(this.data, text), + getResource: (res: ResourceDimensionConfig) => getResourceDimensionFromData(this.data, res), + }; + + updateData(data: PanelData) { + this.data = data; + this.root.updateData(this.context); + } + + updateSize(width: number, height: number) { + this.width = width; + this.height = height; + this.style = { width, height }; + this.root.updateSize(width, height); + } + + onChange(uid: number, cfg: CanvasElementOptions) { + const elem = this.lookup.get(uid); + if (!elem) { + throw new Error('element not found: ' + uid + ' // ' + [...this.lookup.keys()]); + } + this.revId++; + elem.onChange(cfg); + elem.updateData(this.context); // Refresh any data that may have changed + this.save(); + } + + save() { + this.onSave(this.root.getSaveModel()); + } + + render() { + return ( +
+ {this.root.render()} +
+ ); + } +} + +const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ + wrap: css` + overflow: hidden; + position: relative; + `, + + toolbar: css` + position: absolute; + bottom: 0; + margin: 10px; + `, +})); diff --git a/public/app/features/canvas/types.ts b/public/app/features/canvas/types.ts new file mode 100644 index 00000000000..872e149cf6b --- /dev/null +++ b/public/app/features/canvas/types.ts @@ -0,0 +1,37 @@ +import { ColorDimensionConfig, ResourceDimensionConfig } from 'app/features/dimensions/types'; + +export interface Placement { + top?: number; + left?: number; + right?: number; + bottom?: number; + + width?: number; + height?: number; +} + +export interface Anchor { + top?: boolean; + left?: boolean; + right?: boolean; + bottom?: boolean; +} + +export enum BackgroundImageSize { + Original = 'original', + Contain = 'contain', + Cover = 'cover', + Fill = 'fill', + Tile = 'tile', +} + +export interface BackgroundConfig { + color?: ColorDimensionConfig; + image?: ResourceDimensionConfig; + size?: BackgroundImageSize; +} + +export interface LineConfig { + color?: ColorDimensionConfig; + width?: number; +} diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 8eb77d33451..577b4d01e5a 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -29,6 +29,7 @@ import { DashboardLoading } from '../components/DashboardLoading/DashboardLoadin import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed'; import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt'; import classnames from 'classnames'; +import { PanelEditExitedEvent } from 'app/types/events'; import { liveTimer } from '../dashgrid/liveTimer'; export interface DashboardPageRouteParams { @@ -182,6 +183,9 @@ export class UnthemedDashboardPage extends PureComponent { // leaving edit mode if (!this.state.editPanel && prevState.editPanel) { dashboardWatcher.setEditingState(false); + + // Some panels need kicked when leaving edit mode + this.props.dashboard?.events.publish(new PanelEditExitedEvent(prevState.editPanel.id)); } if (this.state.editPanelAccessDenied) { diff --git a/public/app/features/dimensions/context.ts b/public/app/features/dimensions/context.ts new file mode 100644 index 00000000000..f6a88777ccd --- /dev/null +++ b/public/app/features/dimensions/context.ts @@ -0,0 +1,14 @@ +import { + ColorDimensionConfig, + DimensionSupplier, + ResourceDimensionConfig, + ScaleDimensionConfig, + TextDimensionConfig, +} from './types'; + +export interface DimensionContext { + getColor(color: ColorDimensionConfig): DimensionSupplier; + getScale(scale: ScaleDimensionConfig): DimensionSupplier; + getText(text: TextDimensionConfig): DimensionSupplier; + getResource(resource: ResourceDimensionConfig): DimensionSupplier; +} diff --git a/public/app/features/dimensions/index.ts b/public/app/features/dimensions/index.ts index 4112597dedf..64cd094c08d 100644 --- a/public/app/features/dimensions/index.ts +++ b/public/app/features/dimensions/index.ts @@ -5,3 +5,4 @@ export * from './scale'; export * from './text'; export * from './utils'; export * from './resource'; +export * from './context'; diff --git a/public/app/features/dimensions/utils.ts b/public/app/features/dimensions/utils.ts index 100cdad3f64..01144b47035 100644 --- a/public/app/features/dimensions/utils.ts +++ b/public/app/features/dimensions/utils.ts @@ -1,4 +1,76 @@ -import { DataFrame, Field, getFieldDisplayName, ReducerID } from '@grafana/data'; +import { DataFrame, PanelData, Field, getFieldDisplayName, ReducerID } from '@grafana/data'; +import { + getColorDimension, + getScaledDimension, + getTextDimension, + getResourceDimension, + ColorDimensionConfig, + DimensionSupplier, + ResourceDimensionConfig, + ScaleDimensionConfig, + TextDimensionConfig, +} from 'app/features/dimensions'; +import { config } from '@grafana/runtime'; + +export function getColorDimensionFromData( + data: PanelData | undefined, + cfg: ColorDimensionConfig +): DimensionSupplier { + if (data?.series && cfg.field) { + for (const frame of data.series) { + const d = getColorDimension(frame, cfg, config.theme2); + if (!d.isAssumed || data.series.length === 1) { + return d; + } + } + } + return getColorDimension(undefined, cfg, config.theme2); +} + +export function getScaleDimensionFromData( + data: PanelData | undefined, + cfg: ScaleDimensionConfig +): DimensionSupplier { + if (data?.series && cfg.field) { + for (const frame of data.series) { + const d = getScaledDimension(frame, cfg); + if (!d.isAssumed || data.series.length === 1) { + return d; + } + } + } + return getScaledDimension(undefined, cfg); +} + +export function getResourceDimensionFromData( + data: PanelData | undefined, + cfg: ResourceDimensionConfig +): DimensionSupplier { + if (data?.series && cfg.field) { + for (const frame of data.series) { + const d = getResourceDimension(frame, cfg); + if (!d.isAssumed || data.series.length === 1) { + return d; + } + } + } + return getResourceDimension(undefined, cfg); +} + +export function getTextDimensionFromData( + data: PanelData | undefined, + cfg: TextDimensionConfig +): DimensionSupplier { + if (data?.series && cfg.field) { + for (const frame of data.series) { + const d = getTextDimension(frame, cfg); + if (!d.isAssumed || data.series.length === 1) { + return d; + } + } + } + return getTextDimension(undefined, cfg); +} export function findField(frame?: DataFrame, name?: string): Field | undefined { if (!frame || !name?.length) { diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 235311897fc..0ea05edba30 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -70,6 +70,7 @@ import * as alertGroupsPanel from 'app/plugins/panel/alertGroups/module'; // Async loaded panels const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module'); +const canvasPanel = async () => await import(/* webpackChunkName: "canvasPanel" */ 'app/plugins/panel/canvas/module'); const builtInPlugins: any = { 'app/plugins/datasource/graphite/module': graphitePlugin, @@ -100,6 +101,7 @@ const builtInPlugins: any = { 'app/plugins/panel/graph/module': graphPanel, 'app/plugins/panel/xychart/module': xyChartPanel, 'app/plugins/panel/geomap/module': geomapPanel, + 'app/plugins/panel/canvas/module': canvasPanel, 'app/plugins/panel/dashlist/module': dashListPanel, 'app/plugins/panel/pluginlist/module': pluginsListPanel, 'app/plugins/panel/alertlist/module': alertListPanel, diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx new file mode 100644 index 00000000000..8bf4f0ab942 --- /dev/null +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -0,0 +1,97 @@ +import { Component } from 'react'; +import { PanelProps } from '@grafana/data'; +import { PanelOptions } from './models.gen'; +import { ReplaySubject, Subscription } from 'rxjs'; +import { PanelEditExitedEvent } from 'app/types/events'; +import { CanvasGroupOptions } from 'app/features/canvas'; +import { Scene } from 'app/features/canvas/runtime/scene'; + +interface Props extends PanelProps {} + +interface State { + refresh: number; +} + +// Used to pass the scene to the editor functions +export const theScene = new ReplaySubject(1); + +export class CanvasPanel extends Component { + readonly scene: Scene; + private subs = new Subscription(); + needsReload = false; + + constructor(props: Props) { + super(props); + this.state = { + refresh: 0, + }; + + // Only the initial options are ever used. + // later changs are all controled by the scene + this.scene = new Scene(this.props.options.root, this.onUpdateScene); + this.scene.updateSize(props.width, props.height); + this.scene.updateData(props.data); + theScene.next(this.scene); // used in the editors + + this.subs.add( + this.props.eventBus.subscribe(PanelEditExitedEvent, (evt) => { + if (this.props.id === evt.payload) { + this.needsReload = true; + } + }) + ); + } + + componentDidMount() { + theScene.next(this.scene); + } + + componentWillUnmount() { + this.subs.unsubscribe(); + } + + // NOTE, all changes to the scene flow through this function + // even the editor gets current state from the same scene instance! + onUpdateScene = (root: CanvasGroupOptions) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + root, + }); + this.setState({ refresh: this.state.refresh + 1 }); + // console.log('send changes', root); + }; + + shouldComponentUpdate(nextProps: Props) { + const { width, height, data, renderCounter } = this.props; + let changed = false; + + if (width !== nextProps.width || height !== nextProps.height) { + this.scene.updateSize(nextProps.width, nextProps.height); + changed = true; + } + if (data !== nextProps.data) { + this.scene.updateData(nextProps.data); + changed = true; + } + + // After editing, the options are valid, but the scene was in a different panel + if (this.needsReload && this.props.options !== nextProps.options) { + this.needsReload = false; + this.scene.load(nextProps.options.root); + this.scene.updateSize(nextProps.width, nextProps.height); + this.scene.updateData(nextProps.data); + changed = true; + } + + if (renderCounter !== nextProps.renderCounter) { + changed = true; + } + + return changed; + } + + render() { + return this.scene.render(); + } +} diff --git a/public/app/plugins/panel/canvas/README.md b/public/app/plugins/panel/canvas/README.md new file mode 100644 index 00000000000..0155feafe13 --- /dev/null +++ b/public/app/plugins/panel/canvas/README.md @@ -0,0 +1,4 @@ +# Canvas panel - Native Plugin + +The Canvas Panel is **included** with Grafana. + diff --git a/public/app/plugins/panel/canvas/editor/ElementEditor.tsx b/public/app/plugins/panel/canvas/editor/ElementEditor.tsx new file mode 100644 index 00000000000..0a036b5fb49 --- /dev/null +++ b/public/app/plugins/panel/canvas/editor/ElementEditor.tsx @@ -0,0 +1,123 @@ +import React, { FC, useMemo } from 'react'; +import { Select } from '@grafana/ui'; +import { DataFrame, PanelOptionsEditorBuilder, StandardEditorContext } from '@grafana/data'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; +import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVizualizationOptions'; +import { cloneDeep } from 'lodash'; +import { addBackgroundOptions, addBorderOptions } from './options'; +import { + CanvasElementItem, + CanvasElementOptions, + canvasElementRegistry, + DEFAULT_CANVAS_ELEMENT_CONFIG, +} from 'app/features/canvas'; + +export interface CanvasElementEditorProps { + options?: CanvasElementOptions; + data: DataFrame[]; // All results + onChange: (options: CanvasElementOptions) => void; + filter?: (item: CanvasElementItem) => boolean; +} + +export const CanvasElementEditor: FC = ({ options, onChange, data, filter }) => { + // all basemaps + const layerTypes = useMemo(() => { + return canvasElementRegistry.selectOptions( + options?.type // the selected value + ? [options.type] // as an array + : [DEFAULT_CANVAS_ELEMENT_CONFIG.type], + filter + ); + }, [options?.type, filter]); + + // The options change with each layer type + const optionsEditorBuilder = useMemo(() => { + const layer = canvasElementRegistry.getIfExists(options?.type); + if (!layer || !layer.registerOptionsUI) { + return null; + } + + const builder = new PanelOptionsEditorBuilder(); + if (layer.registerOptionsUI) { + layer.registerOptionsUI(builder); + } + + addBackgroundOptions(builder); + addBorderOptions(builder); + return builder; + }, [options?.type]); + + // The react componnets + const layerOptions = useMemo(() => { + const layer = canvasElementRegistry.getIfExists(options?.type); + if (!optionsEditorBuilder || !layer) { + return null; + } + + const category = new OptionsPaneCategoryDescriptor({ + id: 'CanvasElement config', + title: 'CanvasElement config', + }); + + const context: StandardEditorContext = { + data, + options: options, + }; + + const currentOptions = { ...options, type: layer.id, config: { ...layer.defaultConfig, ...options?.config } }; + + // Update the panel options if not set + if (!options || (layer.defaultConfig && !options.config)) { + onChange(currentOptions as any); + } + + const reg = optionsEditorBuilder.getRegistry(); + + // Load the options into categories + fillOptionsPaneItems( + reg.list(), + + // Always use the same category + (categoryNames) => category, + + // Custom upate function + (path: string, value: any) => { + onChange(setOptionImmutably(currentOptions, path, value) as any); + }, + context + ); + + return ( + <> +
+ {category.items.map((item) => item.render())} + + ); + }, [optionsEditorBuilder, onChange, data, options]); + + return ( +
+