superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From ccwilli...@apache.org
Subject [incubator-superset] branch master updated: [SIP-5] Refactor Time Series Table (#5775)
Date Thu, 13 Sep 2018 17:40:52 GMT
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 <krist.wongz@gmail.com>
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;


Mime
View raw message