Canvas: add alpha canvas panel and initial interfaces (#37279)

This commit is contained in:
Ryan McKinley 2021-09-02 10:01:08 -07:00 committed by GitHub
parent c19d65b1ad
commit 9a0040c0ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1295 additions and 1 deletions

View 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;
}

View 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),
});
},
};

View 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,
},
};

View 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',
},
});
},
};

View File

@ -0,0 +1,7 @@
import { CanvasElementOptions } from './element';
export interface CanvasGroupOptions extends CanvasElementOptions {
type: 'group';
elements: CanvasElementOptions[];
// layout? // absolute, list, grid?
}

View File

@ -0,0 +1,4 @@
export * from './types';
export * from './element';
export { CanvasGroupOptions } from './group';
export * from './registry';

View 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,
]);

View 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>
);
}
}

View 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()),
};
}
}

View 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;
`,
}));

View 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;
}

View File

@ -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) {

View 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>;
}

View File

@ -5,3 +5,4 @@ export * from './scale';
export * from './text';
export * from './utils';
export * from './resource';
export * from './context';

View File

@ -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) {

View File

@ -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,

View 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();
}
}

View File

@ -0,0 +1,4 @@
# Canvas panel - Native Plugin
The Canvas Panel is **included** with Grafana.

View 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>
);
};

View File

@ -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);
}}
/>
);
};

View 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),
});
}

View 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

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

View 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,
};

View 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,
});
});

View 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"
}
}
}

View File

@ -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';
}