mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 00:47:38 -06:00
Canvas: add alpha canvas panel and initial interfaces (#37279)
This commit is contained in:
parent
c19d65b1ad
commit
9a0040c0ae
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 { 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<Props, State> {
|
||||
// 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) {
|
||||
|
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 './utils';
|
||||
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 {
|
||||
if (!frame || !name?.length) {
|
||||
|
@ -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,
|
||||
|
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> {
|
||||
static type = 'time-range-updated';
|
||||
}
|
||||
export class PanelEditExitedEvent extends BusEventWithPayload<number> {
|
||||
static type = 'panel-edit-finished';
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user