Skip to content

Commit

Permalink
Add autoresize for Vega
Browse files Browse the repository at this point in the history
  • Loading branch information
ktmud committed Jul 2, 2019
1 parent 2e522cc commit 5a22339
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 111 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ client/cypress/screenshots
client/cypress/videos

client/app/assets/less/**/*.css
client/app/visualizations/vega/vega.css
2 changes: 2 additions & 0 deletions client/app/assets/less/redash/query.less
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ edit-in-place p.editable:hover {
}

.query__vis {
height: 100%;

table {
border: 1px solid #f0f0f0;
}
Expand Down
2 changes: 1 addition & 1 deletion client/app/visualizations/VisualizationRenderer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function VisualizationRenderer(props) {
return (
<React.Fragment>
{showFilters && <Filters filters={filters} onChange={setFilters} />}
<div>
<div> {/* TODO: is this DIV really necessary? */}
<Renderer
query={props.query}
options={options}
Expand Down
1 change: 1 addition & 0 deletions client/app/visualizations/vega/Editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export default class VegaEditor extends React.Component {
</Form.Item>
<AceEditor
height="55vh" // 55% viewport height
width="auto"
theme="textmate"
value={spec}
mode={lang}
Expand Down
194 changes: 143 additions & 51 deletions client/app/visualizations/vega/Renderer.jsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,156 @@
import stringify from 'json-stringify-pretty-compact';
import ResizeObserver from 'resize-observer-polyfill';
import { isObject } from 'lodash';
import React from 'react';
import Vega from 'react-vega';
import * as vl from 'vega-lite';
// import * as YAML from 'js-yaml';
import { Handler } from 'vega-tooltip';
import { Alert, Icon } from 'antd';
import LZString from 'lz-string';
import memoize from 'memoize-one';

import { RendererPropTypes } from '../index';
import { Mode, NAMES } from './consts';

import './vega.less';
import { parseSpecText, yaml2json, applyTheme } from './helpers';
import './vega.less';

/**
* Parse Vega spec in props based on chosen language and
* vega mode (lite or not).
*
* @param {object} props properties passed from React
*/
function parseProps({ lang, mode, spec, theme }, data) {
let error = null;

// if empty spec
if (!spec.trim()) {
return { error: 'You entered an empty spec', mode, spec, data: [] };
}
export default class VegaRenderer extends React.PureComponent {
static propTypes = RendererPropTypes;

const parsed = parseSpecText({ spec, lang, mode });
error = parsed.error;
spec = parsed.spec; // if error, spec will be default spec
/**
* Parse Vega spec in props based on chosen language and
* vega mode (lite or not).
*
* Since we used memoization, this function must be an instance method
*
* @param {object} props properties passed from React
*/
parseOptions = memoize(({ lang, mode, spec, theme }, compileLite = true) => {
let error = null;
const parsed = parseSpecText({ spec, lang, mode });
error = parsed.error;
spec = parsed.spec; // if error, spec will be default spec
// In case we updated theme in the JavaScript module,
// but the stored spec still has the old theme
applyTheme(spec, theme);

// In case we updated theme in the JavaScript module,
// but the stored spec still has the old theme
applyTheme(spec, theme);
if (error) {
return { error, mode, spec };
}

if (error) {
return { error, mode, spec, data: [] };
}
// when either width or height is unset, enable autoresize
const { width, height } = spec;
const autoresize = !width || !height;

// If source is VegaLite spec, convert to Vega
if (mode === Mode.VegaLite) {
try {
spec = vl.compile(spec).spec;
} catch (err) {
error = err.message;
// If source is VegaLite spec, convert to Vega
if (compileLite && mode === Mode.VegaLite) {
try {
spec = vl.compile(spec).spec;
} catch (err) {
error = err.message;
}
// revert to origin height if we are doing autoresize
// (Vega-Lite will set default size as 200x200)
if (autoresize) {
spec.width = width;
spec.height = height;
}
}

return { error, mode, spec, autoresize };
});

constructor(props) {
super(props);
this.state = { width: 0, height: 0 };
}

const specData = spec.data && spec.data[0];
if (data && specData && specData.name === 'query_results') {
// Inject actual data to the data source in spec
specData.values = data.rows;
delete specData.url;
delete specData.format;
componentDidMount() {
// eslint-disable-next-line compat/compat
this.resizeObserver = new ResizeObserver((entries) => {
const rect = entries[0].contentRect;
if (rect.width && rect.height) {
this.updateLayout({ parentSize: rect });
}
});
this.updateLayout();
this.resizeObserver.observe(this.elem.offsetParent);
}

return { error, mode, spec, data };
}
componentWillUnmount() {
this.resizeObserver.disconnect();
}

export default class VegaRenderer extends React.Component {
static propTypes = RendererPropTypes;
/**
* Parse component.props
*/
parseProps = ({ options, data }) => {
const { error, mode, spec, autoresize } = this.parseOptions(options);
const specData = spec.data && spec.data[0];
if (data && specData && specData.name === 'current_query') {
// Inject actual data to the data source in spec
specData.values = data.rows;
// ignore `url` and `format` config
delete specData.url;
delete specData.format;
}
if (!specData) {
spec.data = [{ values: data.rows }];
}
return { error, mode, spec, autoresize };
};

// shouldComponentUpdate(nextProps, nextState) {
// return false;
// }
/**
* Updaete width & height in spec based on parent size
* @param {number} width - manual width in pixels
* @param {number} height - manual height in pixels
* @param {number} parentSize - parent width and height
*/
updateLayout({ width, height, parentSize } = {}) {
// when there is error message or element is unmounting
// these elements might be null
if (!this.vega || !this.vega.element) return;
const { spec, autoresize } = this.parseOptions(this.props.options);
if (!spec || !autoresize) return;
if (!parentSize) {
const node = this.elem.offsetParent;
const bounds = node.getBoundingClientRect();
parentSize = {
width: bounds.width,
height: bounds.height,
};
}
const { width: specWidth, height: specHeight } = spec;
let hPadding = 20;
// if from editor, needs space for the edit link
let vPadding = this.props.fromEditor ? 40 : 5;
if (typeof spec.padding === 'number') {
hPadding += 2 * spec.padding;
vPadding += 2 * spec.padding;
} else if (isObject(spec.padding)) {
hPadding += (spec.padding.left || 0) + (spec.padding.right || 0);
vPadding += (spec.padding.top || 0) + (spec.padding.bottom || 0);
}
width = width || specWidth || Math.max(parentSize.width - hPadding, 100);
height = height || specHeight || Math.min(400, Math.max(parentSize.height - vPadding, 300));
if (width !== this.state.width || height !== this.state.height) {
this.setState({ width, height });
}
}

render() {
const props = this.props;
const options = props.options;
const { error, mode, spec } = parseProps(options, props.data);
// parseProps is cached by memoization
const { error, mode, spec, autoresize } = this.parseProps(this.props);
const alertContent = (
<React.Fragment>
{' '}
{error && error !== 'Invalid spec' ? (
<React.Fragment>
{' '}
<strong>{error}</strong>.{' '} <br />
<strong>{error}</strong>. <br />
</React.Fragment>
) : null}{' '}
See{' '}
Expand All @@ -89,21 +164,33 @@ export default class VegaRenderer extends React.Component {
const alertInvalidSpec = (
<Alert message={`Invalid ${NAMES[mode]} Spec`} description={alertContent} type="warning" showIcon />
);
const width = spec.width;
const height = spec.height;
let editLink = null;

// if calling from editor, append an edit link
let editLink = null;
if (this.props.fromEditor) {
const vegaEditorBase = 'https://vega.github.io/editor/';
const vegaUrl = `${vegaEditorBase}#/custom/${mode}/`;

// Obtain the raw spec from text, so we can link to both Vega and Vega-Lite
const updateVegaUrl = (e) => {
let specText = options.spec;
if (options.lang === 'yaml') {
specText = yaml2json(specText, mode).specText;
}
if (autoresize) {
const { width, height } = this.state;
let updatedSpec = { ...spec };
if (mode === Mode.VegaLite) {
updatedSpec = this.parseOptions(this.props.options, false).spec;
}
updatedSpec.width = width;
updatedSpec.height = height;
specText = stringify(updatedSpec);
}
const compressed = LZString.compressToEncodedURIComponent(specText);
e.target.href = `${vegaEditorBase}#/url/${mode}/${compressed}`;
};

editLink = (
<div className="vega-external-link">
<a href={vegaUrl} target="_blank" rel="noopener noreferrer" onClick={updateVegaUrl}>
Expand All @@ -114,7 +201,12 @@ export default class VegaRenderer extends React.Component {
}

return (
<div className="vega-visualization-container">
<div
className="vega-visualization-container"
ref={(elem) => {
this.elem = elem;
}}
>
{error ? (
alertInvalidSpec
) : (
Expand All @@ -123,9 +215,9 @@ export default class VegaRenderer extends React.Component {
ref={(elem) => {
this.vega = elem;
}}
width={this.state.width}
height={this.state.height}
spec={spec}
width={width}
height={height}
enableHover
tooltip={new Handler().call}
/>
Expand Down
4 changes: 1 addition & 3 deletions client/app/visualizations/vega/consts.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ export const NAMES = {
export const VEGA_LITE_START_SPEC = `{
"$schema": "https://vega.github.io/schema/vega-lite/v3.json",
"description": "{{ query.name }}",
"width": "500",
"height": "300",
"autosize": "fit",
"data": {
"name": "query_results",
"name": "current_query",
"url": "{{ dataUrl }}",
"format": {
"type": "csv",
Expand Down
2 changes: 1 addition & 1 deletion client/app/visualizations/vega/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function parseSpecText({ spec: specText, lang, mode }) {

// if empty string, return the default spec
if (!specText || !specText.trim()) {
return { error, spec };
return { error: 'You entered an empty spec', spec };
}
// if lang is not specified, try parse as JSON first
if (!lang || lang === 'json') {
Expand Down
1 change: 1 addition & 0 deletions client/app/visualizations/vega/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const redashThemeBase = {
},

legend: {
titleFontSize: 11,
labelFontSize: 11,
padding: 1,
symbolSize: 30,
Expand Down
54 changes: 0 additions & 54 deletions client/app/visualizations/vega/vega.css

This file was deleted.

5 changes: 4 additions & 1 deletion client/app/visualizations/vega/vega.less
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@
}

#vg-tooltip-element {
z-index: 2000;
z-index: 2001;
table {
background: transparent;
}
}
Loading

0 comments on commit 5a22339

Please sign in to comment.