Skip to content
This repository has been archived by the owner on Jul 9, 2022. It is now read-only.

Commit

Permalink
Add support for Vega and Vega-lite
Browse files Browse the repository at this point in the history
Vega and Vega-Lite allow users to specify a JSON configuration
to declaratively define a complex interactive visualization.

It could thoeretically become the foundation of many visualizations
we already have.

This commit adds integration with Vega and Vega-Lite, with a lot
of code borrowed from the official Vega Editor[1].

[1] https://vega.github.io/editor/
  • Loading branch information
ktmud committed Jun 26, 2019
1 parent 1743800 commit 59b2bad
Show file tree
Hide file tree
Showing 21 changed files with 2,366 additions and 342 deletions.
6 changes: 4 additions & 2 deletions client/app/components/queries/api-key-dialog.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getQueryDataUrl } from './index';

const ApiKeyDialog = {
template: `<div class="modal-header">
<button type="button" class="close" aria-label="Close" ng-click="$ctrl.close()"><span aria-hidden="true">&times;</span></button>
Expand Down Expand Up @@ -29,8 +31,8 @@ const ApiKeyDialog = {
this.canEdit = currentUser.id === this.resolve.query.user.id || currentUser.hasPermission('admin');
this.disableRegenerateApiKeyButton = false;
this.query = this.resolve.query;
this.csvUrlBase = `${clientConfig.basePath}api/queries/${this.resolve.query.id}/results.csv?api_key=`;
this.jsonUrlBase = `${clientConfig.basePath}api/queries/${this.resolve.query.id}/results.json?api_key=`;
this.csvUrlBase = getQueryDataUrl(this.resolve.query.id, 'csv');
this.jsonUrlBase = getQueryDataUrl(this.resolve.query.id, 'json');

this.regenerateQueryApiKey = () => {
this.disableRegenerateApiKeyButton = true;
Expand Down
9 changes: 9 additions & 0 deletions client/app/components/queries/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { clientConfig } from '@/services/auth';

export default {};

export const getQueryDataUrl = (queryId, format, apiKey = '') => (
`${clientConfig.basePath}api/queries/${queryId}/results.${format}${
apiKey === false ? '' : ('?api_key=' + apiKey)
}`
);
2 changes: 1 addition & 1 deletion client/app/pages/queries/query.html
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ <h3>
</li>
</ul>
<div class="query__vis m-t-15 p-b-15 scrollbox" data-test="QueryPageVisualization{{ selectedVisualization.id }}">
<visualization-renderer visualization="selectedVisualization" query-result="queryResult"></visualization-renderer>
<visualization-renderer query="query" visualization="selectedVisualization" query-result="queryResult"></visualization-renderer>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion client/app/services/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class Parameter {
}

if (isArray(value) && (value.length === 2)) {
value = [moment(value[0]), moment(value[1])];
value = [moment(value[0], 'YYYY-MM-DD'), moment(value[1], 'YYYY-MM-DD')];
if (value[0].isValid() && value[1].isValid()) {
this.value = {
start: value[0].format(DATETIME_FORMATS[this.type]),
Expand Down
9 changes: 7 additions & 2 deletions client/app/visualizations/EditVisualizationDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })

const [saveInProgress, setSaveInProgress] = useState(false);

const { Renderer, Editor } = registeredVisualizations[type];

function onTypeChanged(newType) {
setType(newType);

Expand Down Expand Up @@ -129,8 +131,6 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
confirmDialogClose(nameChanged || optionsChanged).then(dialog.dismiss);
}

const { Renderer, Editor } = registeredVisualizations[type];

return (
<Modal
{...dialog.props}
Expand Down Expand Up @@ -177,6 +177,8 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
<Editor
data={data}
options={options}
query={query}
visualization={visualization}
visualizationName={name}
onOptionsChange={onOptionsChanged}
/>
Expand All @@ -189,8 +191,11 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
<Renderer
data={filteredData}
options={options}
query={query}
visualization={visualization}
visualizationName={name}
onOptionsChange={onOptionsChanged}
fromEditor
/>
</div>
</Grid.Col>
Expand Down
22 changes: 16 additions & 6 deletions client/app/visualizations/VisualizationRenderer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { registeredVisualizations, VisualizationType } from './index';

function combineFilters(localFilters, globalFilters) {
// tiny optimization - to avoid unnecessary updates
if ((localFilters.length === 0) || (globalFilters.length === 0)) {
if (localFilters.length === 0 || globalFilters.length === 0) {
return localFilters;
}

Expand Down Expand Up @@ -38,10 +38,13 @@ export function VisualizationRenderer(props) {
setFilters(combineFilters(filters, props.filters));
}, [props.filters]);

const filteredData = useMemo(() => ({
columns: data.columns,
rows: filterData(data.rows, filters),
}), [data, filters]);
const filteredData = useMemo(
() => ({
columns: data.columns || [],
rows: filterData(data.rows, filters),
}),
[data, filters],
);

const { showFilters, visualization } = props;
const { Renderer, getOptions } = registeredVisualizations[visualization.type];
Expand All @@ -51,14 +54,21 @@ export function VisualizationRenderer(props) {
<React.Fragment>
{showFilters && <Filters filters={filters} onChange={setFilters} />}
<div>
<Renderer options={options} data={filteredData} visualizationName={visualization.name} />
<Renderer
query={props.query}
options={options}
data={filteredData}
visualization={visualization}
visualizationName={visualization.name}
/>
</div>
</React.Fragment>
);
}

VisualizationRenderer.propTypes = {
visualization: VisualizationType.isRequired,
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResult: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
filters: FiltersType,
showFilters: PropTypes.bool,
Expand Down
1 change: 1 addition & 0 deletions client/app/visualizations/chart/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export default function init(ngModule) {
registerVisualization({
type: 'CHART',
name: 'Chart',
isDefault: true,
getOptions: options => merge({}, DEFAULT_OPTIONS, {
showDataLabels: options.globalSeriesType === 'pie',
dateTimeFormat: clientConfig.dateTimeFormat,
Expand Down
15 changes: 13 additions & 2 deletions client/app/visualizations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const VisualizationType = PropTypes.shape({

// For each visualization's renderer
export const RendererPropTypes = {
fromEditor: PropTypes.bool,
query: PropTypes.object.isRequired,
visualization: PropTypes.object,
visualizationName: PropTypes.string,
data: Data.isRequired,
options: VisualizationOptions.isRequired,
Expand All @@ -28,6 +31,8 @@ export const RendererPropTypes = {

// For each visualization's editor
export const EditorPropTypes = {
query: PropTypes.object.isRequired,
visualization: PropTypes.object,
visualizationName: PropTypes.string,
data: Data.isRequired,
options: VisualizationOptions.isRequired,
Expand All @@ -45,6 +50,7 @@ const VisualizationConfig = PropTypes.shape({
name: PropTypes.string.isRequired,
getOptions: PropTypes.func.isRequired, // (existingOptions: object, data: { columns[], rows[] }) => object
isDeprecated: PropTypes.bool,
isDefault: PropTypes.bool,
Renderer: PropTypes.func.isRequired,
Editor: PropTypes.func,

Expand Down Expand Up @@ -80,11 +86,16 @@ export function registerVisualization(config) {
-----------------------------------------------------------*/

export function getDefaultVisualization() {
return find(registeredVisualizations, visualization => !visualization.isDeprecated);
return find(
registeredVisualizations,
viz => !viz.isDeprecated && viz.isDefault,
);
}

export function newVisualization(type = null, options = {}) {
const visualization = type ? registeredVisualizations[type] : getDefaultVisualization();
const visualization = type
? registeredVisualizations[type]
: getDefaultVisualization();
return {
type: visualization.type,
name: visualization.name,
Expand Down
210 changes: 210 additions & 0 deletions client/app/visualizations/vega/Editor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import React from 'react';
import { Form, Select, message, Icon } from 'antd';
import MonacoEditor from 'react-monaco-editor';
import { debounce } from 'lodash';
import stringify from 'json-stringify-pretty-compact';
import * as monaco from 'monaco-editor';
import * as YAML from 'js-yaml';

import { EditorPropTypes } from '../index';
import { Mode, MONACO_SCHEMAS, THEMES, THEME_NAMES, DEFAULT_OPTIONS } from './consts';
import { renderInitialSpecText, parseSpecText, applyTheme, dumpSpecText } from './helpers';

// Add Schema supports
const monacoDiagnostics = {
allowComments: false,
enableSchemaRequest: false,
validate: true,
schemas: MONACO_SCHEMAS,
};

const jsonFormatter = {
provideDocumentFormattingEdits(model) {
return [
{
range: model.getFullModelRange(),
text: stringify(JSON.parse(model.getValue())),
},
];
},
};

const yamlFormatter = {
provideDocumentFormattingEdits(model) {
return [
{
range: model.getFullModelRange(),
text: YAML.safeDump(YAML.safeLoads(model.getValue())),
},
];
},
};

/**
* Add additional language support for Monaco editor
*/
function setupEditor() {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions(monacoDiagnostics);
monaco.languages.registerDocumentFormattingEditProvider('json', jsonFormatter);
monaco.languages.registerDocumentFormattingEditProvider('yaml', yamlFormatter);
}

export default class VegaEditor extends React.Component {
static propTypes = EditorPropTypes;

constructor(props) {
super(props);
this.editor = null; // reference to the Monaco editor instance.
this.state = { ...props.options };
this.buffers = {}; // Editor model buffer based on lang & mode
this.updateSpec = this.updateSpec.bind(this);
this.updateLang = this.updateLang.bind(this);
this.updateTheme = this.updateTheme.bind(this);
this.updateEditorBuffer = this.updateEditorBuffer.bind(this);
this.editorDidMount = this.editorDidMount.bind(this);
this.componentWillUnmount = this.componentWillUnmount.bind(this);
}

componentWillUnmount() {
Object.values(this.buffers).forEach(buf => buf.model.dispose());
}

getEditorBuffer(targetState) {
const { spec, lang, mode, theme } = { ...this.state, ...targetState };
const { lang: origLang, mode: origMode } = this.state;
const uri = `internal://server/${mode}.${lang}`;
const bufs = this.buffers;
let model = monaco.editor.getModel(uri);
if (!model) {
const initialValue = renderInitialSpecText(
{
spec,
lang,
mode,
theme,
origLang,
origMode,
},
this.props,
);
model = monaco.editor.createModel(initialValue, lang, uri);
}
bufs[uri] = bufs[uri] || { model, viewState: null };
return bufs[uri];
}

setOption(options, callback) {
this.setState(options, (...args) => {
// propagage the update to parent (EditVisualizationDialog)
this.props.onOptionsChange({ ...this.state });
if (callback) {
callback.apply(this, ...args);
}
});
}

updateLang(lang) {
this.updateEditorBuffer({ lang });
}

updateMode(mode) {
this.updateEditorBuffer({ mode });
}

updateSpec(spec) {
this.setOption({ spec });
}

updateTheme(theme) {
const { spec: specText, lang, mode } = this.state;
const { error, spec } = parseSpecText({ spec: specText, lang, mode });
if (error) {
message.error('Theme not applied because your spec is invalid.');
}
applyTheme(spec, theme);
const updatedSpecText = dumpSpecText(spec, this.state.lang);
this.setOption({ spec: updatedSpecText, theme });
}

/**
* Update editor buffer corresponds to the target lang & mode
*/
updateEditorBuffer(targetState = {}) {
if (!this.editor) return;
const editor = this.editor;
const newBuf = this.getEditorBuffer(targetState);
const curModel = editor.getModel();
if (curModel === newBuf.model) {
return;
}
if (curModel) {
this.buffers[curModel.uri].viewState = editor.saveViewState();
}
editor.setModel(newBuf.model);
if (newBuf.viewState) {
editor.restoreViewState(newBuf.viewState);
}
// the updated spec from the new model
targetState.spec = newBuf.model.getValue();
// once language/mode changed, get editor content and use as current spec
this.setOption(targetState);
}

editorDidMount(editor) {
this.editor = editor;
this.updateEditorBuffer();
}

render() {
const { lang, mode, spec, theme: _theme } = this.state;
const monacoOptions = {
model: null,
automaticLayout: true,
folding: true,
minimap: { enabled: false },
readOnly: false,
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: '13px',
};
// make sure theme is acceptable value
const theme = THEMES.includes(_theme) ? _theme : DEFAULT_OPTIONS.theme;

return (
<div className="vega-spec-editor">
<Form.Item>
<Select style={{ width: '7em' }} value={lang} onChange={target => this.updateLang(target)}>
<Select.Option key="yaml"> YAML </Select.Option>
<Select.Option key="json"> JSON </Select.Option>
</Select>
<Select style={{ width: '8em' }} value={mode} onChange={target => this.updateMode(target)}>
<Select.Option key={Mode.Vega}> Vega </Select.Option>
<Select.Option key={Mode.VegaLite}> Vega Lite </Select.Option>
</Select>
<Select
style={{ width: '14em' }}
defaultValue="custom"
value={theme}
onChange={target => this.updateTheme(target)}
>
{THEMES.map(value => (
<Select.Option key={value}> {THEME_NAMES[value]} </Select.Option>
))}
</Select>
<a className="vega-help-link" href="https://vega.github.io/vega-lite/" target="_blank" rel="noopener noreferrer">
<Icon type="question-circle" /> What is Vega?
</a>
</Form.Item>
<MonacoEditor
height="55vh" // 55% viewport height
theme="vs-light"
value={spec}
options={monacoOptions}
onChange={debounce(this.updateSpec, 1000)}
editorWillMount={setupEditor}
editorDidMount={this.editorDidMount}
/>
</div>
);
}
}
Loading

0 comments on commit 59b2bad

Please sign in to comment.