mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Canvas: add alpha canvas panel and initial interfaces (#37279)
This commit is contained in:
56
public/app/features/canvas/element.ts
Normal file
56
public/app/features/canvas/element.ts
Normal file
@@ -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<TConfig = any> {
|
||||||
|
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<TConfig = any, TData = any> {
|
||||||
|
// Saved config
|
||||||
|
config: TConfig;
|
||||||
|
|
||||||
|
// Calculated position info
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
// Raw data
|
||||||
|
data?: TData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas item builder
|
||||||
|
*
|
||||||
|
* @alpha
|
||||||
|
*/
|
||||||
|
export interface CanvasElementItem<TConfig = any, TData = any> 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<CanvasElementProps<TConfig, TData>>;
|
||||||
|
|
||||||
|
/** Build the configuraiton UI */
|
||||||
|
registerOptionsUI?: (builder: PanelOptionsEditorBuilder<CanvasElementOptions<TConfig>>) => void;
|
||||||
|
}
|
||||||
149
public/app/features/canvas/elements/icon.tsx
Normal file
149
public/app/features/canvas/elements/icon.tsx
Normal file
@@ -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 (
|
||||||
|
<SVG
|
||||||
|
src={data.path}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={svgStyle}
|
||||||
|
className={svgStyle.strokeWidth ? svgStrokePathClass : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
34
public/app/features/canvas/elements/notFound.tsx
Normal file
34
public/app/features/canvas/elements/notFound.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
|
import { CanvasElementItem, CanvasElementProps } from '../element';
|
||||||
|
|
||||||
|
interface NotFoundConfig {
|
||||||
|
orig?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotFoundDisplay extends PureComponent<CanvasElementProps<NotFoundConfig>> {
|
||||||
|
render() {
|
||||||
|
const { config } = this.props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>NOT FOUND:</h3>
|
||||||
|
<pre>{JSON.stringify(config, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notFoundItem: CanvasElementItem<NotFoundConfig> = {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
147
public/app/features/canvas/elements/textBox.tsx
Normal file
147
public/app/features/canvas/elements/textBox.tsx
Normal file
@@ -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<CanvasElementProps<TextBoxConfig, TextBoxData>> {
|
||||||
|
render() {
|
||||||
|
const { data } = this.props;
|
||||||
|
const styles = getStyles(config.theme2, data);
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<span className={styles.span}>{data?.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<TextBoxConfig, TextBoxData> = {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
7
public/app/features/canvas/group.ts
Normal file
7
public/app/features/canvas/group.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { CanvasElementOptions } from './element';
|
||||||
|
|
||||||
|
export interface CanvasGroupOptions extends CanvasElementOptions {
|
||||||
|
type: 'group';
|
||||||
|
elements: CanvasElementOptions[];
|
||||||
|
// layout? // absolute, list, grid?
|
||||||
|
}
|
||||||
4
public/app/features/canvas/index.ts
Normal file
4
public/app/features/canvas/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './element';
|
||||||
|
export { CanvasGroupOptions } from './group';
|
||||||
|
export * from './registry';
|
||||||
15
public/app/features/canvas/registry.ts
Normal file
15
public/app/features/canvas/registry.ts
Normal file
@@ -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<CanvasElementItem>(() => [
|
||||||
|
iconItem, // default for now
|
||||||
|
textBoxItem,
|
||||||
|
]);
|
||||||
137
public/app/features/canvas/runtime/element.tsx
Normal file
137
public/app/features/canvas/runtime/element.tsx
Normal file
@@ -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 (
|
||||||
|
<div key={`${this.UID}/${this.revId}`} style={this.style}>
|
||||||
|
<item.display config={this.options.config} width={this.width} height={this.height} data={this.data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
public/app/features/canvas/runtime/group.tsx
Normal file
82
public/app/features/canvas/runtime/group.tsx
Normal file
@@ -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 <div>GROUP!</div>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div key={`${this.UID}/${this.revId}`} style={this.style}>
|
||||||
|
{this.elements.map((v) => v.render())}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
116
public/app/features/canvas/runtime/scene.tsx
Normal file
116
public/app/features/canvas/runtime/scene.tsx
Normal file
@@ -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<number, ElementState>();
|
||||||
|
styles = getStyles(config.theme2);
|
||||||
|
readonly selected = new ReplaySubject<ElementState | undefined>(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 (
|
||||||
|
<div key={this.revId} className={this.styles.wrap} style={this.style}>
|
||||||
|
{this.root.render()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
||||||
|
wrap: css`
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
`,
|
||||||
|
|
||||||
|
toolbar: css`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 10px;
|
||||||
|
`,
|
||||||
|
}));
|
||||||
37
public/app/features/canvas/types.ts
Normal file
37
public/app/features/canvas/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { DashboardLoading } from '../components/DashboardLoading/DashboardLoadin
|
|||||||
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
|
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
|
||||||
import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
|
import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
import { PanelEditExitedEvent } from 'app/types/events';
|
||||||
import { liveTimer } from '../dashgrid/liveTimer';
|
import { liveTimer } from '../dashgrid/liveTimer';
|
||||||
|
|
||||||
export interface DashboardPageRouteParams {
|
export interface DashboardPageRouteParams {
|
||||||
@@ -182,6 +183,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
|||||||
// leaving edit mode
|
// leaving edit mode
|
||||||
if (!this.state.editPanel && prevState.editPanel) {
|
if (!this.state.editPanel && prevState.editPanel) {
|
||||||
dashboardWatcher.setEditingState(false);
|
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) {
|
if (this.state.editPanelAccessDenied) {
|
||||||
|
|||||||
14
public/app/features/dimensions/context.ts
Normal file
14
public/app/features/dimensions/context.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import {
|
||||||
|
ColorDimensionConfig,
|
||||||
|
DimensionSupplier,
|
||||||
|
ResourceDimensionConfig,
|
||||||
|
ScaleDimensionConfig,
|
||||||
|
TextDimensionConfig,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export interface DimensionContext {
|
||||||
|
getColor(color: ColorDimensionConfig): DimensionSupplier<string>;
|
||||||
|
getScale(scale: ScaleDimensionConfig): DimensionSupplier<number>;
|
||||||
|
getText(text: TextDimensionConfig): DimensionSupplier<string>;
|
||||||
|
getResource(resource: ResourceDimensionConfig): DimensionSupplier<string>;
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from './scale';
|
|||||||
export * from './text';
|
export * from './text';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './resource';
|
export * from './resource';
|
||||||
|
export * from './context';
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<number> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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 {
|
export function findField(frame?: DataFrame, name?: string): Field | undefined {
|
||||||
if (!frame || !name?.length) {
|
if (!frame || !name?.length) {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import * as alertGroupsPanel from 'app/plugins/panel/alertGroups/module';
|
|||||||
|
|
||||||
// Async loaded panels
|
// Async loaded panels
|
||||||
const geomapPanel = async () => await import(/* webpackChunkName: "geomapPanel" */ 'app/plugins/panel/geomap/module');
|
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 = {
|
const builtInPlugins: any = {
|
||||||
'app/plugins/datasource/graphite/module': graphitePlugin,
|
'app/plugins/datasource/graphite/module': graphitePlugin,
|
||||||
@@ -100,6 +101,7 @@ const builtInPlugins: any = {
|
|||||||
'app/plugins/panel/graph/module': graphPanel,
|
'app/plugins/panel/graph/module': graphPanel,
|
||||||
'app/plugins/panel/xychart/module': xyChartPanel,
|
'app/plugins/panel/xychart/module': xyChartPanel,
|
||||||
'app/plugins/panel/geomap/module': geomapPanel,
|
'app/plugins/panel/geomap/module': geomapPanel,
|
||||||
|
'app/plugins/panel/canvas/module': canvasPanel,
|
||||||
'app/plugins/panel/dashlist/module': dashListPanel,
|
'app/plugins/panel/dashlist/module': dashListPanel,
|
||||||
'app/plugins/panel/pluginlist/module': pluginsListPanel,
|
'app/plugins/panel/pluginlist/module': pluginsListPanel,
|
||||||
'app/plugins/panel/alertlist/module': alertListPanel,
|
'app/plugins/panel/alertlist/module': alertListPanel,
|
||||||
|
|||||||
97
public/app/plugins/panel/canvas/CanvasPanel.tsx
Normal file
97
public/app/plugins/panel/canvas/CanvasPanel.tsx
Normal file
@@ -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<PanelOptions> {}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
refresh: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used to pass the scene to the editor functions
|
||||||
|
export const theScene = new ReplaySubject<Scene>(1);
|
||||||
|
|
||||||
|
export class CanvasPanel extends Component<Props, State> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
public/app/plugins/panel/canvas/README.md
Normal file
4
public/app/plugins/panel/canvas/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Canvas panel - Native Plugin
|
||||||
|
|
||||||
|
The Canvas Panel is **included** with Grafana.
|
||||||
|
|
||||||
123
public/app/plugins/panel/canvas/editor/ElementEditor.tsx
Normal file
123
public/app/plugins/panel/canvas/editor/ElementEditor.tsx
Normal file
@@ -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<TConfig = any> {
|
||||||
|
options?: CanvasElementOptions<TConfig>;
|
||||||
|
data: DataFrame[]; // All results
|
||||||
|
onChange: (options: CanvasElementOptions<TConfig>) => void;
|
||||||
|
filter?: (item: CanvasElementItem) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CanvasElementEditor: FC<CanvasElementEditorProps> = ({ 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<CanvasElementOptions>();
|
||||||
|
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<any> = {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
{category.items.map((item) => item.render())}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [optionsEditorBuilder, onChange, data, options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
menuShouldPortal
|
||||||
|
options={layerTypes.options}
|
||||||
|
value={layerTypes.current}
|
||||||
|
onChange={(v) => {
|
||||||
|
const layer = canvasElementRegistry.getIfExists(v.value);
|
||||||
|
if (!layer) {
|
||||||
|
console.warn('layer does not exist', v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...options, // keep current options
|
||||||
|
type: layer.id,
|
||||||
|
config: cloneDeep(layer.defaultConfig ?? {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{layerOptions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { StandardEditorProps } from '@grafana/data';
|
||||||
|
import { PanelOptions } from '../models.gen';
|
||||||
|
import { CanvasElementEditor } from './ElementEditor';
|
||||||
|
import { theScene } from '../CanvasPanel';
|
||||||
|
import { useObservable } from 'react-use';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { CanvasGroupOptions } from 'app/features/canvas';
|
||||||
|
|
||||||
|
export const SelectedElementEditor: FC<StandardEditorProps<CanvasGroupOptions, any, PanelOptions>> = ({ context }) => {
|
||||||
|
const scene = useObservable(theScene);
|
||||||
|
const selected = useObservable(scene?.selected ?? of(undefined));
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
return <div>No item is selected</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CanvasElementEditor
|
||||||
|
options={selected.options}
|
||||||
|
data={context.data}
|
||||||
|
onChange={(cfg) => {
|
||||||
|
scene!.onChange(selected.UID, cfg);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
public/app/plugins/panel/canvas/editor/options.ts
Normal file
66
public/app/plugins/panel/canvas/editor/options.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { PanelOptionsEditorBuilder } from '@grafana/data';
|
||||||
|
import { BackgroundImageSize } from 'app/features/canvas';
|
||||||
|
import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors';
|
||||||
|
|
||||||
|
export function addBackgroundOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||||
|
builder
|
||||||
|
.addCustomEditor({
|
||||||
|
id: 'background.color',
|
||||||
|
path: 'background.color',
|
||||||
|
name: 'Background Color',
|
||||||
|
editor: ColorDimensionEditor,
|
||||||
|
settings: {},
|
||||||
|
defaultValue: {
|
||||||
|
// Configured values
|
||||||
|
fixed: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addCustomEditor({
|
||||||
|
id: 'background.image',
|
||||||
|
path: 'background.image',
|
||||||
|
name: 'Background Image',
|
||||||
|
editor: ResourceDimensionEditor,
|
||||||
|
settings: {
|
||||||
|
resourceType: 'image',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addRadio({
|
||||||
|
path: 'background.size',
|
||||||
|
name: 'Backround image size',
|
||||||
|
settings: {
|
||||||
|
options: [
|
||||||
|
{ value: BackgroundImageSize.Original, label: 'Original' },
|
||||||
|
{ value: BackgroundImageSize.Contain, label: 'Contain' },
|
||||||
|
{ value: BackgroundImageSize.Cover, label: 'Cover' },
|
||||||
|
{ value: BackgroundImageSize.Fill, label: 'Fill' },
|
||||||
|
{ value: BackgroundImageSize.Tile, label: 'Tile' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: BackgroundImageSize.Cover,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBorderOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||||
|
builder.addSliderInput({
|
||||||
|
path: 'border.width',
|
||||||
|
name: 'Border Width',
|
||||||
|
defaultValue: 2,
|
||||||
|
settings: {
|
||||||
|
min: 0,
|
||||||
|
max: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addCustomEditor({
|
||||||
|
id: 'border.color',
|
||||||
|
path: 'border.color',
|
||||||
|
name: 'Border Color',
|
||||||
|
editor: ColorDimensionEditor,
|
||||||
|
settings: {},
|
||||||
|
defaultValue: {
|
||||||
|
// Configured values
|
||||||
|
fixed: '',
|
||||||
|
},
|
||||||
|
showIf: (cfg) => Boolean(cfg.border?.width),
|
||||||
|
});
|
||||||
|
}
|
||||||
9
public/app/plugins/panel/canvas/img/icn-canvas.svg
Normal file
9
public/app/plugins/panel/canvas/img/icn-canvas.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
|
||||||
|
<defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:#3865ab;}</style><linearGradient id="linear-gradient" y1="40.18" x2="82.99" y2="40.18" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M869,351v543.4H97.6V123.1h596V17.9H81.8C34.9,17.9,10,50.4,10,97.3v814.7c0,46.9,45.8,70.1,79.9,70.1h806.4c44.4,0,60.3-23.3,60.3-70.1V351H869z"/>
|
||||||
|
<path class="cls-2" d="M888.2,263.3L765.4,140.6L302.6,606l120,120L888.2,263.3z"/>
|
||||||
|
<path class="cls-1" d="M204.5,824.3l192.8-70.1L274.6,631.5L204.5,824.3z"/>
|
||||||
|
<path class="cls-1" d="M977.6,111.1l-60-60c-16.6-16.6-43.4-16.6-60,0l-60,60l120,120l60-60C994.1,154.5,994.1,127.6,977.6,111.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1004 B |
29
public/app/plugins/panel/canvas/models.cue
Normal file
29
public/app/plugins/panel/canvas/models.cue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2021 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 grafanaschema
|
||||||
|
|
||||||
|
Family: {
|
||||||
|
lineages: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
PanelOptions: {
|
||||||
|
// anything for now
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
migrations: []
|
||||||
|
}
|
||||||
22
public/app/plugins/panel/canvas/models.gen.ts
Normal file
22
public/app/plugins/panel/canvas/models.gen.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
// NOTE: This file will be auto generated from models.cue
|
||||||
|
// It is currenty hand written but will serve as the target for cuetsy
|
||||||
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
import { CanvasGroupOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
|
||||||
|
|
||||||
|
export const modelVersion = Object.freeze([1, 0]);
|
||||||
|
|
||||||
|
export interface PanelOptions {
|
||||||
|
root: CanvasGroupOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultPanelOptions: PanelOptions = {
|
||||||
|
root: ({
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
...DEFAULT_CANVAS_ELEMENT_CONFIG,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown) as CanvasGroupOptions,
|
||||||
|
};
|
||||||
19
public/app/plugins/panel/canvas/module.tsx
Normal file
19
public/app/plugins/panel/canvas/module.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { PanelPlugin } from '@grafana/data';
|
||||||
|
|
||||||
|
import { CanvasPanel } from './CanvasPanel';
|
||||||
|
import { SelectedElementEditor } from './editor/SelectedElementEditor';
|
||||||
|
import { defaultPanelOptions, PanelOptions } from './models.gen';
|
||||||
|
|
||||||
|
export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||||
|
.setNoPadding() // extend to panel edges
|
||||||
|
.useFieldConfig()
|
||||||
|
.setPanelOptions((builder) => {
|
||||||
|
builder.addCustomEditor({
|
||||||
|
category: ['Selected Element'],
|
||||||
|
id: 'root',
|
||||||
|
path: 'root', // multiple elements may edit root!
|
||||||
|
name: 'Selected Element',
|
||||||
|
editor: SelectedElementEditor,
|
||||||
|
defaultValue: defaultPanelOptions.root,
|
||||||
|
});
|
||||||
|
});
|
||||||
18
public/app/plugins/panel/canvas/plugin.json
Normal file
18
public/app/plugins/panel/canvas/plugin.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"name": "Canvas",
|
||||||
|
"id": "canvas",
|
||||||
|
"state": "alpha",
|
||||||
|
|
||||||
|
"info": {
|
||||||
|
"description": "Explict element placement",
|
||||||
|
"author": {
|
||||||
|
"name": "Grafana Labs",
|
||||||
|
"url": "https://grafana.com"
|
||||||
|
},
|
||||||
|
"logos": {
|
||||||
|
"small": "img/icn-canvas.svg",
|
||||||
|
"large": "img/icn-canvas.svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,3 +201,6 @@ export class AnnotationQueryFinished extends BusEventWithPayload<AnnotationQuery
|
|||||||
export class TimeRangeUpdatedEvent extends BusEventWithPayload<TimeRange> {
|
export class TimeRangeUpdatedEvent extends BusEventWithPayload<TimeRange> {
|
||||||
static type = 'time-range-updated';
|
static type = 'time-range-updated';
|
||||||
}
|
}
|
||||||
|
export class PanelEditExitedEvent extends BusEventWithPayload<number> {
|
||||||
|
static type = 'panel-edit-finished';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user