You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2017/10/04 17:17:35 UTC
[incubator-superset] branch master updated: New "Time Series -
Table" visualization (#3543)
This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new bb0f69d New "Time Series - Table" visualization (#3543)
bb0f69d is described below
commit bb0f69d074286185609cae06436ab04b70978d5d
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Wed Oct 4 10:17:33 2017 -0700
New "Time Series - Table" visualization (#3543)
* [WiP] adding a new "Time Series - Table" viz
* Adding drag-n-drop to collection
* Using keys in arrays
* tests
---
.../assets/images/viz_thumbnails/time_table.png | Bin 0 -> 65153 bytes
.../components/InfoTooltipWithTrigger.jsx | 20 +-
.../assets/javascripts/components/MetricOption.jsx | 21 +-
.../javascripts/explore/components/Control.jsx | 27 +--
.../explore/components/controls/BoundsControl.jsx | 5 -
.../components/controls/CollectionControl.jsx | 119 +++++++++++
.../controls/TimeSeriesColumnControl.jsx | 223 +++++++++++++++++++++
.../explore/components/controls/index.jsx | 33 +++
superset/assets/javascripts/explore/main.css | 5 +-
.../assets/javascripts/explore/stores/controls.jsx | 8 +
.../assets/javascripts/explore/stores/visTypes.js | 19 ++
superset/assets/javascripts/modules/colors.js | 2 +
superset/assets/js_build.sh | 1 -
superset/assets/package.json | 6 +-
.../components/TimeSeriesColumnControl_spec.jsx | 33 +++
superset/assets/stylesheets/superset.less | 7 +
superset/assets/visualizations/main.js | 1 +
superset/assets/visualizations/time_table.css | 3 +
superset/assets/visualizations/time_table.jsx | 173 ++++++++++++++++
superset/viz.py | 94 +++++----
20 files changed, 710 insertions(+), 90 deletions(-)
diff --git a/superset/assets/images/viz_thumbnails/time_table.png b/superset/assets/images/viz_thumbnails/time_table.png
new file mode 100644
index 0000000..5eba0c2
Binary files /dev/null and b/superset/assets/images/viz_thumbnails/time_table.png differ
diff --git a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
index 09632cd..d86d051 100644
--- a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
+++ b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
@@ -5,7 +5,7 @@ import { slugify } from '../modules/utils';
const propTypes = {
label: PropTypes.string.isRequired,
- tooltip: PropTypes.string.isRequired,
+ tooltip: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
onClick: PropTypes.func,
@@ -17,11 +17,21 @@ const defaultProps = {
className: 'text-muted',
placement: 'right',
};
+const tooltipStyle = { wordWrap: 'break-word' };
export default function InfoTooltipWithTrigger({
label, tooltip, icon, className, onClick, placement, bsStyle }) {
const iconClass = `fa fa-${icon} ${className} ${bsStyle ? 'text-' + bsStyle : ''}`;
- const tooltipStyle = { wordWrap: 'break-word' };
+ const iconEl = (
+ <i
+ className={iconClass}
+ onClick={onClick}
+ style={{ cursor: onClick ? 'pointer' : null }}
+ />
+ );
+ if (!tooltip) {
+ return iconEl;
+ }
return (
<OverlayTrigger
placement={placement}
@@ -31,11 +41,7 @@ export default function InfoTooltipWithTrigger({
</Tooltip>
}
>
- <i
- className={iconClass}
- onClick={onClick}
- style={{ cursor: onClick ? 'pointer' : null }}
- />
+ {iconEl}
</OverlayTrigger>
);
}
diff --git a/superset/assets/javascripts/components/MetricOption.jsx b/superset/assets/javascripts/components/MetricOption.jsx
index b190434..f099414 100644
--- a/superset/assets/javascripts/components/MetricOption.jsx
+++ b/superset/assets/javascripts/components/MetricOption.jsx
@@ -5,9 +5,13 @@ import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
const propTypes = {
metric: PropTypes.object.isRequired,
+ showFormula: PropTypes.bool,
+};
+const defaultProps = {
+ showFormula: true,
};
-export default function MetricOption({ metric }) {
+export default function MetricOption({ metric, showFormula }) {
return (
<div>
<span className="m-r-5 option-label">
@@ -21,12 +25,14 @@ export default function MetricOption({ metric }) {
label={`descr-${metric.metric_name}`}
/>
}
- <InfoTooltipWithTrigger
- className="m-r-5 text-muted"
- icon="question-circle-o"
- tooltip={metric.expression}
- label={`expr-${metric.metric_name}`}
- />
+ {showFormula &&
+ <InfoTooltipWithTrigger
+ className="m-r-5 text-muted"
+ icon="question-circle-o"
+ tooltip={metric.expression}
+ label={`expr-${metric.metric_name}`}
+ />
+ }
{metric.warning_text &&
<InfoTooltipWithTrigger
className="m-r-5 text-danger"
@@ -38,3 +44,4 @@ export default function MetricOption({ metric }) {
</div>);
}
MetricOption.propTypes = propTypes;
+MetricOption.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/Control.jsx b/superset/assets/javascripts/explore/components/Control.jsx
index 972ff0d..ed7ea97 100644
--- a/superset/assets/javascripts/explore/components/Control.jsx
+++ b/superset/assets/javascripts/explore/components/Control.jsx
@@ -1,33 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
-import BoundsControl from './controls/BoundsControl';
-import CheckboxControl from './controls/CheckboxControl';
-import ColorSchemeControl from './controls/ColorSchemeControl';
-import DatasourceControl from './controls/DatasourceControl';
-import DateFilterControl from './controls/DateFilterControl';
-import FilterControl from './controls/FilterControl';
-import HiddenControl from './controls/HiddenControl';
-import SelectAsyncControl from './controls/SelectAsyncControl';
-import SelectControl from './controls/SelectControl';
-import TextAreaControl from './controls/TextAreaControl';
-import TextControl from './controls/TextControl';
-import VizTypeControl from './controls/VizTypeControl';
+import controlMap from './controls';
-const controlMap = {
- BoundsControl,
- CheckboxControl,
- DatasourceControl,
- DateFilterControl,
- FilterControl,
- HiddenControl,
- SelectControl,
- TextAreaControl,
- TextControl,
- VizTypeControl,
- ColorSchemeControl,
- SelectAsyncControl,
-};
const controlTypes = Object.keys(controlMap);
const propTypes = {
diff --git a/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx b/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx
index 776f7a4..803a539 100644
--- a/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx
+++ b/superset/assets/javascripts/explore/components/controls/BoundsControl.jsx
@@ -5,16 +5,11 @@ import ControlHeader from '../ControlHeader';
import { t } from '../../../locales';
const propTypes = {
- name: PropTypes.string.isRequired,
- label: PropTypes.string,
- description: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.array,
};
const defaultProps = {
- label: null,
- description: null,
onChange: () => {},
value: [null, null],
};
diff --git a/superset/assets/javascripts/explore/components/controls/CollectionControl.jsx b/superset/assets/javascripts/explore/components/controls/CollectionControl.jsx
new file mode 100644
index 0000000..74f0d9b
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/CollectionControl.jsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { ListGroup, ListGroupItem } from 'react-bootstrap';
+import shortid from 'shortid';
+import {
+ SortableContainer, SortableHandle, SortableElement, arrayMove,
+} from 'react-sortable-hoc';
+
+import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger';
+import ControlHeader from '../ControlHeader';
+
+const propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ description: PropTypes.string,
+ placeholder: PropTypes.string,
+ addTooltip: PropTypes.string,
+ itemGenerator: PropTypes.func,
+ keyAccessor: PropTypes.func,
+ onChange: PropTypes.func,
+ value: PropTypes.oneOfType([
+ PropTypes.array,
+ ]),
+ isFloat: PropTypes.bool,
+ isInt: PropTypes.bool,
+ control: PropTypes.func,
+};
+
+const defaultProps = {
+ label: null,
+ description: null,
+ onChange: () => {},
+ placeholder: 'Empty collection',
+ itemGenerator: () => ({ key: shortid.generate() }),
+ keyAccessor: o => o.key,
+ value: [],
+ addTooltip: 'Add an item',
+};
+const SortableListGroupItem = SortableElement(ListGroupItem);
+const SortableListGroup = SortableContainer(ListGroup);
+const SortableDragger = SortableHandle(() => (
+ <i className="fa fa-bars text-primary" style={{ cursor: 'ns-resize' }} />));
+
+export default class CollectionControl extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onAdd = this.onAdd.bind(this);
+ }
+ onChange(i, value) {
+ Object.assign(this.props.value[i], value);
+ this.props.onChange(this.props.value);
+ }
+ onAdd() {
+ this.props.onChange(this.props.value.concat([this.props.itemGenerator()]));
+ }
+ onSortEnd({ oldIndex, newIndex }) {
+ this.props.onChange(arrayMove(this.props.value, oldIndex, newIndex));
+ }
+ removeItem(i) {
+ this.props.onChange(this.props.value.filter((o, ix) => i !== ix));
+ }
+ renderList() {
+ if (this.props.value.length === 0) {
+ return <div className="text-muted">{this.props.placeholder}</div>;
+ }
+ return (
+ <SortableListGroup
+ useDragHandle
+ lockAxis="y"
+ onSortEnd={this.onSortEnd.bind(this)}
+ >
+ {this.props.value.map((o, i) => (
+ <SortableListGroupItem
+ className="clearfix"
+ key={this.props.keyAccessor(o)}
+ index={i}
+ >
+ <div className="pull-left m-r-5">
+ <SortableDragger />
+ </div>
+ <div className="pull-left">
+ <this.props.control
+ {...o}
+ onChange={this.onChange.bind(this, i)}
+ />
+ </div>
+ <div className="pull-right">
+ <InfoTooltipWithTrigger
+ icon="times"
+ label="remove-item"
+ tooltip="remove item"
+ bsStyle="primary"
+ onClick={this.removeItem.bind(this, i)}
+ />
+ </div>
+ </SortableListGroupItem>))}
+ </SortableListGroup>
+ );
+ }
+ render() {
+ return (
+ <div>
+ <ControlHeader {...this.props} />
+ {this.renderList()}
+ <InfoTooltipWithTrigger
+ icon="plus-circle"
+ label="add-item"
+ tooltip={this.props.addTooltip}
+ bsStyle="primary"
+ className="fa-lg"
+ onClick={this.onAdd}
+ />
+ </div>
+ );
+ }
+}
+
+CollectionControl.propTypes = propTypes;
+CollectionControl.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/controls/TimeSeriesColumnControl.jsx b/superset/assets/javascripts/explore/components/controls/TimeSeriesColumnControl.jsx
new file mode 100644
index 0000000..cd8ec98
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/TimeSeriesColumnControl.jsx
@@ -0,0 +1,223 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Row, Col, FormControl, OverlayTrigger, Popover,
+} from 'react-bootstrap';
+import Select from 'react-select';
+
+import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger';
+import BoundsControl from './BoundsControl';
+
+const propTypes = {
+ onChange: PropTypes.func,
+};
+
+const defaultProps = {
+ onChange: () => {},
+};
+
+const comparisonTypeOptions = [
+ { value: 'value', label: 'Actual value' },
+ { value: 'diff', label: 'Difference' },
+ { value: 'perc', label: 'Percentage' },
+ { value: 'perc_change', label: 'Percentage Change' },
+];
+
+const colTypeOptions = [
+ { value: 'time', label: 'Time Comparison' },
+ { value: 'contrib', label: 'Contribution' },
+ { value: 'spark', label: 'Sparkline' },
+ { value: 'avg', label: 'Period Average' },
+];
+
+export default class TimeSeriesColumnControl extends React.Component {
+ constructor(props) {
+ super(props);
+ const state = Object.assign({}, props);
+ delete state.onChange;
+ this.state = state;
+ this.onChange = this.onChange.bind(this);
+ }
+ onChange() {
+ this.props.onChange(this.state);
+ }
+ onSelectChange(attr, opt) {
+ this.setState({ [attr]: opt.value }, this.onChange);
+ }
+ onTextInputChange(attr, event) {
+ this.setState({ [attr]: event.target.value }, this.onChange);
+ }
+ onBoundsChange(bounds) {
+ this.setState({ bounds }, this.onChange);
+ }
+ setType() {
+ }
+ textSummary() {
+ return `${this.state.label}`;
+ }
+ edit() {
+ }
+ formRow(label, tooltip, ttLabel, control) {
+ return (
+ <Row style={{ marginTop: '5px' }}>
+ <Col md={5}>
+ {label}{' '}
+ <InfoTooltipWithTrigger
+ placement="top"
+ tooltip={tooltip}
+ label={ttLabel}
+ />
+ </Col>
+ <Col md={7}>{control}</Col>
+ </Row>
+ );
+ }
+ renderPopover() {
+ return (
+ <Popover id="ts-col-popo" title="Column Configuration">
+ <div style={{ width: '280px' }}>
+ {this.formRow(
+ 'Label',
+ 'The column header label',
+ 'time-lag',
+ <FormControl
+ value={this.state.label}
+ onChange={this.onTextInputChange.bind(this, 'label')}
+ bsSize="small"
+ placeholder="Label"
+ />,
+ )}
+ {this.formRow(
+ 'Tooltip',
+ 'Column header tooltip',
+ 'col-tooltip',
+ <FormControl
+ value={this.state.tooltip}
+ onChange={this.onTextInputChange.bind(this, 'tooltip')}
+ bsSize="small"
+ placeholder="Tooltip"
+ />,
+ )}
+ {this.formRow(
+ 'Type',
+ 'Type of comparison, value difference or percentage',
+ 'col-type',
+ <Select
+ value={this.state.colType}
+ clearable={false}
+ onChange={this.onSelectChange.bind(this, 'colType')}
+ options={colTypeOptions}
+ />,
+ )}
+ <hr />
+ {this.state.colType === 'spark' && this.formRow(
+ 'Width',
+ 'Width of the sparkline',
+ 'spark-width',
+ <FormControl
+ value={this.state.width}
+ onChange={this.onTextInputChange.bind(this, 'width')}
+ bsSize="small"
+ placeholder="Width"
+ />,
+ )}
+ {this.state.colType === 'spark' && this.formRow(
+ 'Height',
+ 'Height of the sparkline',
+ 'spark-width',
+ <FormControl
+ value={this.state.height}
+ onChange={this.onTextInputChange.bind(this, 'height')}
+ bsSize="small"
+ placeholder="height"
+ />,
+ )}
+ {['time', 'avg'].indexOf(this.state.colType) >= 0 && this.formRow(
+ 'Time Lag',
+ 'Number of periods to compare against',
+ 'time-lag',
+ <FormControl
+ value={this.state.timeLag}
+ onChange={this.onTextInputChange.bind(this, 'timeLag')}
+ bsSize="small"
+ placeholder="Time Lag"
+ />,
+ )}
+ {['spark'].indexOf(this.state.colType) >= 0 && this.formRow(
+ 'Time Ratio',
+ 'Number of periods to ratio against',
+ 'time-ratio',
+ <FormControl
+ value={this.state.timeRatio}
+ onChange={this.onTextInputChange.bind(this, 'timeRatio')}
+ bsSize="small"
+ placeholder="Time Lag"
+ />,
+ )}
+ {this.state.colType === 'time' && this.formRow(
+ 'Type',
+ 'Type of comparison, value difference or percentage',
+ 'comp-type',
+ <Select
+ value={this.state.comparisonType}
+ clearable={false}
+ onChange={this.onSelectChange.bind(this, 'comparisonType')}
+ options={comparisonTypeOptions}
+ />,
+ )}
+ {this.state.colType !== 'spark' && this.formRow(
+ 'Bounds',
+ (
+ 'Number bounds used for color coding from red to green. ' +
+ 'Reverse the number for green to red. To get boolean ' +
+ 'red or green without spectrum, you can use either only ' +
+ 'min, or max, depending on whether small or big should be ' +
+ 'green or red.'
+ ),
+ 'bounds',
+ <BoundsControl
+ value={this.state.bounds}
+ onChange={this.onBoundsChange.bind(this)}
+ />,
+ )}
+ {this.formRow(
+ 'D3 format',
+ 'D3 format string',
+ 'd3-format',
+ <FormControl
+ value={this.state.d3format}
+ onChange={this.onTextInputChange.bind(this, 'd3format')}
+ bsSize="small"
+ placeholder="D3 format string"
+ />,
+ )}
+ </div>
+ </Popover>
+ );
+ }
+ render() {
+ return (
+ <span>
+ {this.textSummary()}{' '}
+ <OverlayTrigger
+ container={document.body}
+ trigger="click"
+ rootClose
+ ref="trigger"
+ placement="right"
+ overlay={this.renderPopover()}
+ >
+ <InfoTooltipWithTrigger
+ icon="edit"
+ className="text-primary"
+ onClick={this.edit.bind(this)}
+ label="edit-ts-column"
+ />
+ </OverlayTrigger>
+ </span>
+ );
+ }
+}
+
+TimeSeriesColumnControl.propTypes = propTypes;
+TimeSeriesColumnControl.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/explore/components/controls/index.jsx b/superset/assets/javascripts/explore/components/controls/index.jsx
new file mode 100644
index 0000000..499e605
--- /dev/null
+++ b/superset/assets/javascripts/explore/components/controls/index.jsx
@@ -0,0 +1,33 @@
+import BoundsControl from './BoundsControl';
+import CheckboxControl from './CheckboxControl';
+import CollectionControl from './CollectionControl';
+import ColorSchemeControl from './ColorSchemeControl';
+import DatasourceControl from './DatasourceControl';
+import DateFilterControl from './DateFilterControl';
+import FilterControl from './FilterControl';
+import HiddenControl from './HiddenControl';
+import SelectAsyncControl from './SelectAsyncControl';
+import SelectControl from './SelectControl';
+import TextAreaControl from './TextAreaControl';
+import TextControl from './TextControl';
+import TimeSeriesColumnControl from './TimeSeriesColumnControl';
+import VizTypeControl from './VizTypeControl';
+
+const controlMap = {
+ BoundsControl,
+ CheckboxControl,
+ CollectionControl,
+ ColorSchemeControl,
+ DatasourceControl,
+ DateFilterControl,
+ FilterControl,
+ HiddenControl,
+ SelectAsyncControl,
+ SelectControl,
+ TextAreaControl,
+ TextControl,
+ TimeSeriesColumnControl,
+ VizTypeControl,
+};
+
+export default controlMap;
diff --git a/superset/assets/javascripts/explore/main.css b/superset/assets/javascripts/explore/main.css
index 684fdf0..bc67249 100644
--- a/superset/assets/javascripts/explore/main.css
+++ b/superset/assets/javascripts/explore/main.css
@@ -109,4 +109,7 @@
}
.save-modal-selector {
margin: 10px 0;
-}
\ No newline at end of file
+}
+.list-group {
+ margin-bottom: 10px;
+}
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index 92faad1..57222a6 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -5,6 +5,7 @@ import { ALL_COLOR_SCHEMES, spectrums } from '../../modules/colors';
import MetricOption from '../../components/MetricOption';
import ColumnOption from '../../components/ColumnOption';
import { t } from '../../locales';
+import controlMap from '../components/controls';
const D3_FORMAT_DOCS = 'D3 format syntax: https://github.com/d3/d3-format';
@@ -1410,5 +1411,12 @@ export const controls = {
default: 4,
description: 'Number of decimal places with which to display lift values',
},
+ column_collection: {
+ type: 'CollectionControl',
+ label: t('Time Series Columns'),
+ validators: [v.nonEmpty],
+ control: controlMap.TimeSeriesColumnControl,
+ },
+
};
export default controls;
diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js
index 5648f4b..1d4d79b 100644
--- a/superset/assets/javascripts/explore/stores/visTypes.js
+++ b/superset/assets/javascripts/explore/stores/visTypes.js
@@ -369,6 +369,25 @@ export const visTypes = {
},
},
+ time_table: {
+ label: t('Time Series Table'),
+ controlPanelSections: [
+ {
+ label: t('Query'),
+ expanded: true,
+ controlSetRows: [
+ ['groupby', 'metrics'],
+ ['column_collection'],
+ ],
+ },
+ ],
+ controlOverrides: {
+ groupby: {
+ multiple: false,
+ },
+ },
+ },
+
markup: {
label: t('Markup'),
controlPanelSections: [
diff --git a/superset/assets/javascripts/modules/colors.js b/superset/assets/javascripts/modules/colors.js
index 7fd585f..8e3e521 100644
--- a/superset/assets/javascripts/modules/colors.js
+++ b/superset/assets/javascripts/modules/colors.js
@@ -1,5 +1,7 @@
import d3 from 'd3';
+export const brandColor = '#00A699';
+
// Color related utility functions go in this object
const bnbColors = [
'#ff5a5f', // rausch
diff --git a/superset/assets/js_build.sh b/superset/assets/js_build.sh
index 7e48caa..c717398 100755
--- a/superset/assets/js_build.sh
+++ b/superset/assets/js_build.sh
@@ -5,7 +5,6 @@ npm --version
node --version
npm install -g yarn
yarn
-npm run sync-backend
npm run lint
npm run test
npm run build
diff --git a/superset/assets/package.json b/superset/assets/package.json
index c74f66d..15cfff2 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -40,6 +40,7 @@
"homepage": "http://superset.apache.org/",
"dependencies": {
"@data-ui/event-flow": "0.0.8",
+ "@data-ui/sparkline": "0.0.1",
"babel-register": "^6.24.1",
"bootstrap": "^3.3.6",
"brace": "^0.10.0",
@@ -56,12 +57,12 @@
"distributions": "^1.0.0",
"immutable": "^3.8.2",
"jed": "^1.1.1",
- "po2json": "^0.4.5",
"jquery": "3.1.1",
"lodash.throttle": "^4.1.1",
"moment": "^2.14.1",
"mustache": "^2.2.1",
"nvd3": "1.8.6",
+ "po2json": "^0.4.5",
"prop-types": "^15.6.0",
"react": "^15.6.2",
"react-ace": "^5.0.1",
@@ -70,8 +71,8 @@
"react-alert": "^1.0.14",
"react-bootstrap": "^0.31.2",
"react-bootstrap-table": "^4.0.2",
- "react-datetime": "^2.9.0",
"react-dom": "^15.6.2",
+ "react-datetime": "2.9.0",
"react-gravatar": "^2.6.1",
"react-grid-layout": "^0.14.4",
"react-map-gl": "^3.0.4",
@@ -79,6 +80,7 @@
"react-resizable": "^1.3.3",
"react-select": "1.0.0-rc.3",
"react-select-fast-filter-options": "^0.2.1",
+ "react-sortable-hoc": "^0.6.7",
"react-split-pane": "^0.1.66",
"react-syntax-highlighter": "^5.7.0",
"react-virtualized": "^9.3.0",
diff --git a/superset/assets/spec/javascripts/explore/components/TimeSeriesColumnControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/TimeSeriesColumnControl_spec.jsx
new file mode 100644
index 0000000..2f8cd6b
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/TimeSeriesColumnControl_spec.jsx
@@ -0,0 +1,33 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { FormControl, OverlayTrigger } from 'react-bootstrap';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it, beforeEach } from 'mocha';
+import { shallow } from 'enzyme';
+
+import TimeSeriesColumnControl from '../../../../javascripts/explore/components/controls/TimeSeriesColumnControl';
+
+const defaultProps = {
+ name: 'x_axis_label',
+ label: 'X Axis Label',
+ onChange: sinon.spy(),
+};
+
+describe('SelectControl', () => {
+ let wrapper;
+ let inst;
+ beforeEach(() => {
+ wrapper = shallow(<TimeSeriesColumnControl {...defaultProps} />);
+ inst = wrapper.instance();
+ });
+
+ it('renders an OverlayTrigger', () => {
+ expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
+ });
+
+ it('renders an Popover', () => {
+ const popOver = shallow(inst.renderPopover());
+ expect(popOver.find(FormControl)).to.have.lengthOf(3);
+ });
+});
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index a4bb70a..78f26bb 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -1,4 +1,5 @@
@import './less/index.less';
+@import "./less/cosmo/variables.less";
body {
margin: 0px !important;
@@ -364,6 +365,9 @@ iframe {
.PopoverSection {
padding-bottom: 10px;
}
+.popover {
+ max-width: 500px !important;
+}
.float-left {
float: left;
}
@@ -382,3 +386,6 @@ g.annotation-container {
stroke-width: 1;
}
}
+.stroke-primary {
+ stroke: @brand-primary;
+}
diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js
index d5c3abb..dc5ee30 100644
--- a/superset/assets/visualizations/main.js
+++ b/superset/assets/visualizations/main.js
@@ -27,6 +27,7 @@ const vizMap = {
separator: require('./markup.js'),
sunburst: require('./sunburst.js'),
table: require('./table.js'),
+ time_table: require('./time_table.jsx'),
treemap: require('./treemap.js'),
country_map: require('./country_map.js'),
word_cloud: require('./word_cloud.js'),
diff --git a/superset/assets/visualizations/time_table.css b/superset/assets/visualizations/time_table.css
new file mode 100644
index 0000000..5f8a41b
--- /dev/null
+++ b/superset/assets/visualizations/time_table.css
@@ -0,0 +1,3 @@
+.time-table {
+ overflow: auto;
+}
diff --git a/superset/assets/visualizations/time_table.jsx b/superset/assets/visualizations/time_table.jsx
new file mode 100644
index 0000000..928352c
--- /dev/null
+++ b/superset/assets/visualizations/time_table.jsx
@@ -0,0 +1,173 @@
+import ReactDOM from 'react-dom';
+import React from 'react';
+import propTypes from 'prop-types';
+import { Table, Thead, Th } from 'reactable';
+import d3 from 'd3';
+import { Sparkline, LineSeries, PointSeries } from '@data-ui/sparkline';
+
+import MetricOption from '../javascripts/components/MetricOption';
+import TooltipWrapper from '../javascripts/components/TooltipWrapper';
+import { d3format, brandColor } from '../javascripts/modules/utils';
+import InfoTooltipWithTrigger from '../javascripts/components/InfoTooltipWithTrigger';
+import './time_table.css';
+
+const SPARK_MARGIN = 3;
+const ACCESSIBLE_COLOR_BOUNDS = ['#ca0020', '#0571b0'];
+
+function FormattedNumber({ num, format }) {
+ if (format) {
+ return (
+ <span title={num}>{d3format(format, num)}</span>
+ );
+ }
+ return <span>{num}</span>;
+}
+FormattedNumber.propTypes = {
+ num: propTypes.number.isRequired,
+ format: propTypes.string.isRequired,
+};
+
+function viz(slice, payload) {
+ slice.container.css('overflow', 'auto');
+ slice.container.css('height', slice.height());
+ const recs = payload.data.records;
+ const fd = payload.form_data;
+ const data = Object.keys(recs).sort().map((iso) => {
+ const o = recs[iso];
+ return o;
+ });
+ const reversedData = data.slice();
+ reversedData.reverse();
+ const metricMap = {};
+ slice.datasource.metrics.forEach((m) => {
+ metricMap[m.metric_name] = m;
+ });
+
+ let metrics;
+ if (payload.data.is_group_by) {
+ // Sorting by first column desc
+ metrics = payload.data.columns.sort((m1, m2) => (
+ reversedData[0][m1] > reversedData[0][m2] ? -1 : 1
+ ));
+ } else {
+ // Using ordering specified in Metrics dropdown
+ metrics = payload.data.columns;
+ }
+ const tableData = metrics.map((metric) => {
+ let leftCell;
+ if (!payload.data.is_group_by) {
+ leftCell = <MetricOption metric={metricMap[metric]} showFormula={false} />;
+ } else {
+ leftCell = metric;
+ }
+ const row = { metric: leftCell };
+ fd.column_collection.forEach((c) => {
+ if (c.colType === 'spark') {
+ let sparkData;
+ if (!c.timeRatio) {
+ sparkData = data.map(d => d[metric]);
+ } else {
+ // Period ratio sparkline
+ sparkData = [];
+ for (let i = c.timeRatio; i < data.length; i++) {
+ sparkData.push(data[i][metric] / data[i - c.timeRatio][metric]);
+ }
+ }
+ const extent = d3.extent(data, d => d[metric]);
+ const tooltip = `min: ${extent[0]}, max: ${extent[1]}`;
+ row[c.key] = (
+ <TooltipWrapper label="tt-spark" tooltip={tooltip}>
+ <div>
+ <Sparkline
+ ariaLabel={`spark-${metric}`}
+ width={parseInt(c.width, 10) || 300}
+ height={parseInt(c.height, 10) || 50}
+ margin={{
+ top: SPARK_MARGIN,
+ bottom: SPARK_MARGIN,
+ left: SPARK_MARGIN,
+ right: SPARK_MARGIN,
+ }}
+ data={sparkData}
+ >
+ <LineSeries
+ showArea={false}
+ stroke={brandColor}
+ />
+ <PointSeries
+ points={['min', 'max', 'last']}
+ fill={brandColor}
+ />
+ </Sparkline>
+ </div>
+ </TooltipWrapper>);
+ } else {
+ const recent = reversedData[0][metric];
+ let v;
+ if (c.colType === 'time') {
+ // Time lag ratio
+ v = reversedData[parseInt(c.timeLag, 10)][metric];
+ if (c.comparisonType === 'diff') {
+ v -= recent;
+ } else if (c.comparisonType === 'perc') {
+ v /= recent;
+ } else if (c.comparisonType === 'perc_change') {
+ v = (v / recent) - 1;
+ }
+ } else if (c.colType === 'contrib') {
+ // contribution to column total
+ v = recent / Object.keys(reversedData[0])
+ .map(k => reversedData[0][k])
+ .reduce((a, b) => a + b);
+ } else if (c.colType === 'avg') {
+ // Average over the last {timeLag}
+ v = reversedData
+ .map((k, i) => i < c.timeLag ? k[metric] : 0)
+ .reduce((a, b) => a + b) / c.timeLag;
+ }
+ let color;
+ if (c.bounds && c.bounds[0] !== null && c.bounds[1] !== null) {
+ const scaler = d3.scale.linear()
+ .domain([
+ c.bounds[0],
+ c.bounds[0] + ((c.bounds[1] - c.bounds[0]) / 2),
+ c.bounds[1]])
+ .range([ACCESSIBLE_COLOR_BOUNDS[0], 'grey', ACCESSIBLE_COLOR_BOUNDS[1]]);
+ color = scaler(v);
+ } else if (c.bounds && c.bounds[0] !== null) {
+ color = v >= c.bounds[0] ? ACCESSIBLE_COLOR_BOUNDS[1] : ACCESSIBLE_COLOR_BOUNDS[0];
+ } else if (c.bounds && c.bounds[1] !== null) {
+ color = v < c.bounds[1] ? ACCESSIBLE_COLOR_BOUNDS[1] : ACCESSIBLE_COLOR_BOUNDS[0];
+ }
+ row[c.key] = (
+ <span style={{ color }}>
+ <FormattedNumber num={v} format={c.d3format} />
+ </span>);
+ }
+ });
+ return row;
+ });
+ ReactDOM.render(
+ <Table
+ className="table table-condensed"
+ data={tableData}
+ >
+ <Thead>
+ <Th column="metric">Metric</Th>
+ {fd.column_collection.map((c, i) => (
+ <Th column={c.key} key={c.key} width={c.colType === 'spark' ? '1%' : null}>
+ {c.label} {c.tooltip && (
+ <InfoTooltipWithTrigger
+ tooltip={c.tooltip}
+ label={`tt-col-${i}`}
+ placement="top"
+ />
+ )}
+ </Th>))}
+ </Thead>
+ </Table>,
+ document.getElementById(slice.containerId),
+ );
+}
+
+module.exports = viz;
diff --git a/superset/viz.py b/superset/viz.py
index 22d2ea9..2283e8d 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -10,12 +10,13 @@ from __future__ import unicode_literals
import copy
import hashlib
+import inspect
import logging
import traceback
import uuid
import zlib
-from collections import OrderedDict, defaultdict
+from collections import defaultdict
from itertools import product
from datetime import datetime, timedelta
@@ -422,6 +423,48 @@ class TableViz(BaseViz):
return super(TableViz, self).json_dumps(obj)
+class TimeTableViz(BaseViz):
+
+ """A data table with rich time-series related columns"""
+
+ viz_type = "time_table"
+ verbose_name = _("Time Table View")
+ credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original'
+ is_timeseries = True
+
+ def query_obj(self):
+ d = super(TimeTableViz, self).query_obj()
+ fd = self.form_data
+
+ if not fd.get('metrics'):
+ raise Exception(_("Pick at least one metric"))
+
+ if fd.get('groupby') and len(fd.get('metrics')) > 1:
+ raise Exception(_(
+ "When using 'Group By' you are limited to use "
+ "a single metric"))
+ return d
+
+ def get_data(self, df):
+ fd = self.form_data
+ values = self.metrics
+ columns = None
+ if fd.get('groupby'):
+ values = self.metrics[0]
+ columns = fd.get('groupby')
+ pt = df.pivot_table(
+ index=DTTM_ALIAS,
+ columns=columns,
+ values=values)
+ pt.index = pt.index.map(str)
+ pt = pt.sort_index()
+ return dict(
+ records=pt.to_dict(orient='index'),
+ columns=list(pt.columns),
+ is_group_by=len(fd.get('groupby')) > 0,
+ )
+
+
class PivotTableViz(BaseViz):
"""A pivot table view, define your rows, columns and metrics"""
@@ -1669,6 +1712,7 @@ class MapboxViz(BaseViz):
"color": fd.get("mapbox_color"),
}
+
class EventFlowViz(BaseViz):
"""A visualization to explore patterns in event sequences"""
@@ -1684,7 +1728,8 @@ class EventFlowViz(BaseViz):
event_key = form_data.get('all_columns_x')
entity_key = form_data.get('entity')
meta_keys = [
- col for col in form_data.get('all_columns') if col != event_key and col != entity_key
+ col for col in form_data.get('all_columns')
+ if col != event_key and col != entity_key
]
query['columns'] = [event_key, entity_key] + meta_keys
@@ -1758,42 +1803,9 @@ class PairedTTestViz(BaseViz):
return data
-viz_types_list = [
- TableViz,
- PivotTableViz,
- NVD3TimeSeriesViz,
- NVD3DualLineViz,
- NVD3CompareTimeSeriesViz,
- NVD3TimeSeriesStackedViz,
- NVD3TimeSeriesBarViz,
- DistributionBarViz,
- DistributionPieViz,
- BubbleViz,
- BulletViz,
- MarkupViz,
- WordCloudViz,
- BigNumberViz,
- BigNumberTotalViz,
- SunburstViz,
- DirectedForceViz,
- SankeyViz,
- CountryMapViz,
- ChordViz,
- WorldMapViz,
- FilterBoxViz,
- IFrameViz,
- ParallelCoordinatesViz,
- HeatmapViz,
- BoxPlotViz,
- TreemapViz,
- CalHeatmapViz,
- HorizonViz,
- MapboxViz,
- HistogramViz,
- SeparatorViz,
- EventFlowViz,
- PairedTTestViz,
-]
-
-viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list
- if v.viz_type not in config.get('VIZ_TYPE_BLACKLIST')])
+viz_types = {
+ o.viz_type: o for o in globals().values()
+ if (
+ inspect.isclass(o) and
+ issubclass(o, BaseViz) and
+ o.viz_type not in config.get('VIZ_TYPE_BLACKLIST'))}
--
To stop receiving notification emails like this one, please contact
['"commits@superset.apache.org" <co...@superset.apache.org>'].