This is an automated email from the ASF dual-hosted git repository.
ccwilliams 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 7098ada [SIP-5] Refactor Time Series Table (#5775)
7098ada is described below
commit 7098ada8c5e241ba59b985478c1249da89b9b676
Author: Krist Wongsuphasawat <[email protected]>
AuthorDate: Thu Sep 13 10:40:46 2018 -0700
[SIP-5] Refactor Time Series Table (#5775)
* Break TimeTable into smaller pieces
* extract function to compute color
* Handle height and scrollbar
* sort out isGroupBy
* Set default values
* Specify proptypes for data
* rename fields and update proptypes
* Add default props
* remove commented line
* swap import
---
.../visualizations/TimeTable/FormattedNumber.jsx | 27 ++
.../{ => TimeTable}/SparklineCell.jsx | 4 +-
.../src/visualizations/TimeTable/TimeTable.css | 3 +
.../src/visualizations/TimeTable/TimeTable.jsx | 327 +++++++++++++++++++++
superset/assets/src/visualizations/index.js | 2 +-
superset/assets/src/visualizations/time_table.css | 3 -
superset/assets/src/visualizations/time_table.jsx | 208 -------------
7 files changed, 360 insertions(+), 214 deletions(-)
diff --git a/superset/assets/src/visualizations/TimeTable/FormattedNumber.jsx
b/superset/assets/src/visualizations/TimeTable/FormattedNumber.jsx
new file mode 100644
index 0000000..eabbb0e
--- /dev/null
+++ b/superset/assets/src/visualizations/TimeTable/FormattedNumber.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { d3format } from '../../modules/utils';
+
+const propTypes = {
+ num: PropTypes.number,
+ format: PropTypes.string,
+};
+
+const defaultProps = {
+ num: 0,
+ format: undefined,
+};
+
+function FormattedNumber({ num, format }) {
+ if (format) {
+ return (
+ <span title={num}>{d3format(format, num)}</span>
+ );
+ }
+ return <span>{num}</span>;
+}
+
+FormattedNumber.propTypes = propTypes;
+FormattedNumber.defaultProps = defaultProps;
+
+export default FormattedNumber;
diff --git a/superset/assets/src/visualizations/SparklineCell.jsx
b/superset/assets/src/visualizations/TimeTable/SparklineCell.jsx
similarity index 97%
rename from superset/assets/src/visualizations/SparklineCell.jsx
rename to superset/assets/src/visualizations/TimeTable/SparklineCell.jsx
index 9ca272e..1a49e35 100644
--- a/superset/assets/src/visualizations/SparklineCell.jsx
+++ b/superset/assets/src/visualizations/TimeTable/SparklineCell.jsx
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Sparkline, LineSeries, PointSeries, HorizontalReferenceLine,
VerticalReferenceLine, WithTooltip } from '@data-ui/sparkline';
-import { d3format } from '../modules/utils';
-import { getTextDimension } from '../modules/visUtils';
+import { d3format } from '../../modules/utils';
+import { getTextDimension } from '../../modules/visUtils';
const propTypes = {
className: PropTypes.string,
diff --git a/superset/assets/src/visualizations/TimeTable/TimeTable.css
b/superset/assets/src/visualizations/TimeTable/TimeTable.css
new file mode 100644
index 0000000..5f8a41b
--- /dev/null
+++ b/superset/assets/src/visualizations/TimeTable/TimeTable.css
@@ -0,0 +1,3 @@
+.time-table {
+ overflow: auto;
+}
diff --git a/superset/assets/src/visualizations/TimeTable/TimeTable.jsx
b/superset/assets/src/visualizations/TimeTable/TimeTable.jsx
new file mode 100644
index 0000000..ec85262
--- /dev/null
+++ b/superset/assets/src/visualizations/TimeTable/TimeTable.jsx
@@ -0,0 +1,327 @@
+import ReactDOM from 'react-dom';
+import React from 'react';
+import PropTypes from 'prop-types';
+import d3 from 'd3';
+import Mustache from 'mustache';
+import { Table, Thead, Th, Tr, Td } from 'reactable';
+
+import MetricOption from '../../components/MetricOption';
+import { formatDateThunk } from '../../modules/dates';
+import { d3format } from '../../modules/utils';
+import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
+import FormattedNumber from './FormattedNumber';
+import SparklineCell from './SparklineCell';
+import './TimeTable.css';
+
+const ACCESSIBLE_COLOR_BOUNDS = ['#ca0020', '#0571b0'];
+
+function colorFromBounds(value, bounds, colorBounds = ACCESSIBLE_COLOR_BOUNDS)
{
+ if (bounds) {
+ const [min, max] = bounds;
+ const [minColor, maxColor] = colorBounds;
+ if (min !== null && max !== null) {
+ const colorScale = d3.scale.linear()
+ .domain([min, (max + min) / 2, max])
+ .range([minColor, 'grey', maxColor]);
+ return colorScale(value);
+ } else if (min !== null) {
+ return value >= min ? maxColor : minColor;
+ } else if (max !== null) {
+ return value < max ? maxColor : minColor;
+ }
+ }
+ return null;
+}
+
+const propTypes = {
+ className: PropTypes.string,
+ height: PropTypes.number,
+ // Example
+ // {'2018-04-14 00:00:00': { 'SUM(metric_value)': 80031779.40047 }}
+ data: PropTypes.objectOf(PropTypes.objectOf(PropTypes.number)).isRequired,
+ columnConfigs: PropTypes.arrayOf(PropTypes.shape({
+ colType: PropTypes.string,
+ comparisonType: PropTypes.string,
+ d3format: PropTypes.string,
+ key: PropTypes.string,
+ label: PropTypes.string,
+ timeLag: PropTypes.number,
+ })).isRequired,
+ rows: PropTypes.arrayOf(PropTypes.oneOfType([
+ PropTypes.shape({
+ label: PropTypes.string,
+ }),
+ PropTypes.shape({
+ metric_name: PropTypes.string,
+ }),
+ ])).isRequired,
+ rowType: PropTypes.oneOf(['column', 'metric']).isRequired,
+ url: PropTypes.string,
+};
+const defaultProps = {
+ className: '',
+ height: undefined,
+ url: '',
+};
+
+class TimeTable extends React.PureComponent {
+ renderLeftCell(row) {
+ const { rowType, url } = this.props;
+ const context = { metric: row };
+ const fullUrl = url ? Mustache.render(url, context) : null;
+
+ if (rowType === 'column') {
+ const column = row;
+ if (fullUrl) {
+ return (
+ <a
+ href={fullUrl}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ {column.label}
+ </a>
+ );
+ }
+ return column.label;
+ }
+
+ const metric = row;
+ return (
+ <MetricOption
+ metric={metric}
+ url={fullUrl}
+ showFormula={false}
+ openInNewWindow
+ />
+ );
+ }
+
+ renderSparklineCell(valueField, column, entries) {
+ let sparkData;
+ if (column.timeRatio) {
+ // Period ratio sparkline
+ sparkData = [];
+ for (let i = column.timeRatio; i < entries.length; i++) {
+ const prevData = entries[i - column.timeRatio][valueField];
+ if (prevData && prevData !== 0) {
+ sparkData.push(entries[i][valueField] / prevData);
+ } else {
+ sparkData.push(null);
+ }
+ }
+ } else {
+ sparkData = entries.map(d => d[valueField]);
+ }
+
+ const formatDate = formatDateThunk(column.dateFormat);
+
+ return (
+ <Td
+ column={column.key}
+ key={column.key}
+ value={sparkData[sparkData.length - 1]}
+ >
+ <SparklineCell
+ width={parseInt(column.width, 10) || 300}
+ height={parseInt(column.height, 10) || 50}
+ data={sparkData}
+ ariaLabel={`spark-${valueField}`}
+ numberFormat={column.d3format}
+ yAxisBounds={column.yAxisBounds}
+ showYAxis={column.showYAxis}
+ renderTooltip={({ index }) => (
+ <div>
+ <strong>{d3format(column.d3Format, sparkData[index])}</strong>
+ <div>{formatDate(entries[index].time)}</div>
+ </div>
+ )}
+ />
+ </Td>
+ );
+ }
+
+ renderValueCell(valueField, column, reversedEntries) {
+ const recent = reversedEntries[0][valueField];
+ let v;
+ let errorMsg;
+ if (column.colType === 'time') {
+ // Time lag ratio
+ const { timeLag } = column;
+ const totalLag = Object.keys(reversedEntries).length;
+ if (timeLag > totalLag) {
+ errorMsg = `The time lag set at ${timeLag} exceeds the length of data
at ${reversedData.length}. No data available.`;
+ } else {
+ v = reversedEntries[timeLag][valueField];
+ }
+ if (column.comparisonType === 'diff') {
+ v = recent - v;
+ } else if (column.comparisonType === 'perc') {
+ v = recent / v;
+ } else if (column.comparisonType === 'perc_change') {
+ v = (recent / v) - 1;
+ }
+ v = v || 0;
+ } else if (column.colType === 'contrib') {
+ // contribution to column total
+ v = recent / Object.keys(reversedEntries[0])
+ .map(k => k !== 'time' ? reversedEntries[0][k] : null)
+ .reduce((a, b) => a + b);
+ } else if (column.colType === 'avg') {
+ // Average over the last {timeLag}
+ v = reversedEntries
+ .map((k, i) => i < column.timeLag ? k[valueField] : 0)
+ .reduce((a, b) => a + b) / column.timeLag;
+ }
+
+ const color = colorFromBounds(v, column.bounds);
+
+ return (
+ <Td
+ column={column.key}
+ key={column.key}
+ value={v}
+ style={color && {
+ boxShadow: `inset 0px -2.5px 0px 0px ${color}`,
+ borderRight: '2px solid #fff',
+ }}
+ >
+ {errorMsg
+ ? (<div>{errorMsg}</div>)
+ : (<div style={{ color }}>
+ <FormattedNumber num={v} format={column.d3format} />
+ </div>)}
+ </Td>
+ );
+ }
+
+ renderRow(row, entries, reversedEntries) {
+ const { columnConfigs } = this.props;
+ const valueField = row.label || row.metric_name;
+ const leftCell = this.renderLeftCell(row);
+
+ return (
+ <Tr key={leftCell}>
+ <Td column="metric" data={leftCell}>
+ {leftCell}
+ </Td>
+ {columnConfigs.map(c => c.colType === 'spark'
+ ? this.renderSparklineCell(valueField, c, entries)
+ : this.renderValueCell(valueField, c, reversedEntries))}
+ </Tr>
+ );
+ }
+
+ render() {
+ const {
+ className,
+ height,
+ data,
+ columnConfigs,
+ rowType,
+ rows,
+ } = this.props;
+
+ const entries = Object.keys(data)
+ .sort()
+ .map(time => ({ ...data[time], time }));
+ const reversedEntries = entries.concat().reverse();
+
+ const defaultSort = rowType === 'column' ? {
+ column: columnConfigs[0].key,
+ direction: 'desc',
+ } : false;
+
+ return (
+ <div
+ className={`time-table ${className}`}
+ style={{ height }}
+ >
+ <Table
+ className="table table-no-hover"
+ defaultSort={defaultSort}
+ sortBy={defaultSort}
+ sortable={columnConfigs.map(c => c.key)}
+ >
+ <Thead>
+ <Th column="metric">Metric</Th>
+ {columnConfigs.map((c, i) => (
+ <Th
+ key={c.key}
+ column={c.key}
+ width={c.colType === 'spark' ? '1%' : null}
+ >
+ {c.label} {c.tooltip && (
+ <InfoTooltipWithTrigger
+ tooltip={c.tooltip}
+ label={`tt-col-${i}`}
+ placement="top"
+ />
+ )}
+ </Th>))}
+ </Thead>
+ {rows.map(row => this.renderRow(row, entries, reversedEntries))}
+ </Table>
+ </div>
+ );
+ }
+}
+
+TimeTable.propTypes = propTypes;
+TimeTable.defaultProps = defaultProps;
+
+function adaptor(slice, payload) {
+ const { containerId, formData, datasource } = slice;
+ const {
+ column_collection: columnConfigs,
+ groupby,
+ metrics,
+ url,
+ } = formData;
+ const { records, columns } = payload.data;
+ const isGroupBy = groupby.length > 0;
+
+ // When there is a "group by",
+ // each row in the table is a database column
+ // Otherwise,
+ // each row in the table is a metric
+ let rows;
+ if (isGroupBy) {
+ rows = columns.map(column => (typeof column === 'object')
+ ? column
+ : { label: column });
+ } else {
+ const metricMap = datasource.metrics
+ .reduce((acc, current) => {
+ const map = acc;
+ map[current.metric_name] = current;
+ return map;
+ }, {});
+
+ rows = metrics.map(metric => (typeof metric === 'object')
+ ? metric
+ : metricMap[metric]);
+ }
+
+ // TODO: Better parse this from controls instead of mutative value here.
+ columnConfigs.forEach((column) => {
+ const c = column;
+ if (c.timeLag !== undefined && c.timeLag !== null && c.timeLag !== '') {
+ c.timeLag = parseInt(c.timeLag, 10);
+ }
+ });
+
+ ReactDOM.render(
+ <TimeTable
+ height={slice.height()}
+ data={records}
+ columnConfigs={columnConfigs}
+ rows={rows}
+ rowType={isGroupBy ? 'column' : 'metric'}
+ url={url}
+ />,
+ document.getElementById(containerId),
+ );
+}
+
+export default adaptor;
diff --git a/superset/assets/src/visualizations/index.js
b/superset/assets/src/visualizations/index.js
index 91f425b..1f46f5f 100644
--- a/superset/assets/src/visualizations/index.js
+++ b/superset/assets/src/visualizations/index.js
@@ -101,7 +101,7 @@ const vizMap = {
[VIZ_TYPES.sunburst]: () => loadVis(import(/* webpackChunkName: "sunburst"
*/ './sunburst.js')),
[VIZ_TYPES.table]: () => loadVis(import(/* webpackChunkName: "table" */
'./table.js')),
[VIZ_TYPES.time_table]: () =>
- loadVis(import(/* webpackChunkName: "time_table" */ './time_table.jsx')),
+ loadVis(import(/* webpackChunkName: "time_table" */
'./TimeTable/TimeTable.jsx')),
[VIZ_TYPES.treemap]: () => loadVis(import(/* webpackChunkName: "treemap" */
'./treemap.js')),
[VIZ_TYPES.country_map]: () =>
loadVis(import(/* webpackChunkName: "country_map" */ './country_map.js')),
diff --git a/superset/assets/src/visualizations/time_table.css
b/superset/assets/src/visualizations/time_table.css
deleted file mode 100644
index e60b0b3..0000000
--- a/superset/assets/src/visualizations/time_table.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.time_table.slice_container {
- overflow: auto !important;
-}
diff --git a/superset/assets/src/visualizations/time_table.jsx
b/superset/assets/src/visualizations/time_table.jsx
deleted file mode 100644
index d2bf633..0000000
--- a/superset/assets/src/visualizations/time_table.jsx
+++ /dev/null
@@ -1,208 +0,0 @@
-import ReactDOM from 'react-dom';
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Table, Thead, Th, Tr, Td } from 'reactable';
-import d3 from 'd3';
-import Mustache from 'mustache';
-
-import MetricOption from '../components/MetricOption';
-import { formatDateThunk } from '../modules/dates';
-import { d3format } from '../modules/utils';
-import InfoTooltipWithTrigger from '../components/InfoTooltipWithTrigger';
-import SparklineCell from './SparklineCell';
-import './time_table.css';
-
-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,
- format: PropTypes.string,
-};
-
-function viz(slice, payload) {
- slice.container.css('height', slice.height());
- const records = payload.data.records;
- const fd = payload.form_data;
- const data = Object.keys(records).sort().map(iso => ({ ...records[iso], iso
}));
- const reversedData = [...data].reverse();
- const metricMap = {};
- slice.datasource.metrics.forEach((m) => {
- metricMap[m.metric_name] = m;
- });
-
- let metrics;
- let defaultSort = false;
- if (payload.data.is_group_by) {
- metrics = payload.data.columns;
- defaultSort = { column: fd.column_collection[0].key, direction: 'desc' };
- } else {
- metrics = fd.metrics;
- }
- const tableData = metrics.map((metric) => {
- let leftCell;
- const context = { ...fd, metric };
- const url = fd.url ? Mustache.render(fd.url, context) : null;
- const metricLabel = metric.label || metric;
- const metricData = typeof metric === 'object' ? metric : metricMap[metric];
- if (!payload.data.is_group_by) {
- leftCell = (
- <MetricOption metric={metricData} url={url} showFormula={false}
openInNewWindow />
- );
- } else {
- leftCell = url
- ? <a href={url} rel="noopener noreferrer"
target="_blank">{metricLabel}</a>
- : metric;
- }
- const row = { metric: leftCell };
- fd.column_collection.forEach((column) => {
- if (column.colType === 'spark') {
- let sparkData;
- if (!column.timeRatio) {
- sparkData = data.map(d => d[metricLabel]);
- } else {
- // Period ratio sparkline
- sparkData = [];
- for (let i = column.timeRatio; i < data.length; i++) {
- const prevData = data[i - column.timeRatio][metricLabel];
- if (prevData && prevData !== 0) {
- sparkData.push(data[i][metricLabel] / prevData);
- } else {
- sparkData.push(null);
- }
- }
- }
-
- const formatDate = formatDateThunk(column.dateFormat);
-
- row[column.key] = {
- data: sparkData[sparkData.length - 1],
- display: (
- <SparklineCell
- width={parseInt(column.width, 10) || 300}
- height={parseInt(column.height, 10) || 50}
- data={sparkData}
- ariaLabel={`spark-${metricLabel}`}
- numberFormat={column.d3format}
- yAxisBounds={column.yAxisBounds}
- showYAxis={column.showYAxis}
- renderTooltip={({ index }) => (
- <div>
- <strong>{d3format(column.d3Format,
sparkData[index])}</strong>
- <div>{formatDate(data[index].iso)}</div>
- </div>
- )}
- />
- ),
- };
- } else {
- const recent = reversedData[0][metricLabel];
- let v;
- let errorMsg;
- if (column.colType === 'time') {
- // Time lag ratio
- const timeLag = parseInt(column.timeLag, 10);
- const totalLag = Object.keys(reversedData).length;
- if (timeLag > totalLag) {
- errorMsg = `The time lag set at ${timeLag} exceeds the length of
data at ${reversedData.length}. No data available.`;
- } else {
- v = reversedData[timeLag][metricLabel];
- }
- if (column.comparisonType === 'diff') {
- v = recent - v;
- } else if (column.comparisonType === 'perc') {
- v = recent / v;
- } else if (column.comparisonType === 'perc_change') {
- v = (recent / v) - 1;
- }
- v = v || 0;
- } else if (column.colType === 'contrib') {
- // contribution to column total
- v = recent / Object.keys(reversedData[0])
- .map(k => k !== 'iso' ? reversedData[0][k] : null)
- .reduce((a, b) => a + b);
- } else if (column.colType === 'avg') {
- // Average over the last {timeLag}
- v = reversedData
- .map((k, i) => i < column.timeLag ? k[metricLabel] : 0)
- .reduce((a, b) => a + b) / column.timeLag;
- }
- let color;
- if (column.bounds && column.bounds[0] !== null && column.bounds[1] !==
null) {
- const scaler = d3.scale.linear()
- .domain([
- column.bounds[0],
- column.bounds[0] + ((column.bounds[1] - column.bounds[0]) / 2),
- column.bounds[1],
- ])
- .range([ACCESSIBLE_COLOR_BOUNDS[0], 'grey',
ACCESSIBLE_COLOR_BOUNDS[1]]);
- color = scaler(v);
- } else if (column.bounds && column.bounds[0] !== null) {
- color = v >= column.bounds[0] ? ACCESSIBLE_COLOR_BOUNDS[1] :
ACCESSIBLE_COLOR_BOUNDS[0];
- } else if (column.bounds && column.bounds[1] !== null) {
- color = v < column.bounds[1] ? ACCESSIBLE_COLOR_BOUNDS[1] :
ACCESSIBLE_COLOR_BOUNDS[0];
- }
- row[column.key] = {
- data: v,
- display: errorMsg ?
- (<div>{errorMsg}</div>) :
- (<div style={{ color }}>
- <FormattedNumber num={v} format={column.d3format} />
- </div>),
- style: color && {
- boxShadow: `inset 0px -2.5px 0px 0px ${color}`,
- borderRight: '2px solid #fff',
- },
- };
- }
- });
- return row;
- });
-
- ReactDOM.render(
- <Table
- className="table table-no-hover"
- defaultSort={defaultSort}
- sortBy={defaultSort}
- sortable={fd.column_collection.map(c => c.key)}
- >
- <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>
- {tableData.map(row => (
- <Tr key={row.metric}>
- <Td column="metric" data={row.metric}>{row.metric}</Td>
- {fd.column_collection.map(c => (
- <Td
- column={c.key}
- key={c.key}
- value={row[c.key].data}
- style={row[c.key].style}
- >
- {row[c.key].display}
- </Td>))}
- </Tr>))}
- </Table>,
- document.getElementById(slice.containerId),
- );
-}
-
-module.exports = viz;