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
This commit is contained in:
parent
645de384e3
commit
bb0f69d074
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -109,4 +109,7 @@
|
|||
}
|
||||
.save-modal-selector {
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
.list-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import d3 from 'd3';
|
||||
|
||||
export const brandColor = '#00A699';
|
||||
|
||||
// Color related utility functions go in this object
|
||||
const bnbColors = [
|
||||
'#ff5a5f', // rausch
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.time-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue