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
27 changed files with 1295 additions and 1 deletions

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