You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by cc...@apache.org on 2018/08/27 17:42:50 UTC

[incubator-superset] branch master updated: [SIP-5] Repair and refactor Horizon Chart (#5690)

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 d7f06cb  [SIP-5] Repair and refactor Horizon Chart (#5690)
d7f06cb is described below

commit d7f06cbc26bbc1b16684392517b4b510f808dded
Author: Krist Wongsuphasawat <kr...@gmail.com>
AuthorDate: Mon Aug 27 10:42:42 2018 -0700

    [SIP-5] Repair and refactor Horizon Chart (#5690)
    
    * Migrate horizon chart to react
    
    * remove unused code
    
    * rename files
    
    * update props
    
    * enable renderTrigger
    
    * fix canvas transform issue
    
    * Address Chris' comment
---
 superset/assets/src/explore/controls.jsx           |   6 +-
 .../assets/src/visualizations/HorizonChart.css     |  17 ++
 .../assets/src/visualizations/HorizonChart.jsx     | 103 ++++++++++
 superset/assets/src/visualizations/HorizonRow.jsx  | 182 +++++++++++++++++
 superset/assets/src/visualizations/horizon.css     |  17 --
 superset/assets/src/visualizations/horizon.js      | 227 ---------------------
 superset/assets/src/visualizations/index.js        |   2 +-
 7 files changed, 307 insertions(+), 247 deletions(-)

diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 68fcb05..d2be8b0 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -383,14 +383,15 @@ export const controls = {
 
   horizon_color_scale: {
     type: 'SelectControl',
-    label: t('Horizon Color Scale'),
+    renderTrigger: true,
+    label: t('Value Domain'),
     choices: [
       ['series', 'series'],
       ['overall', 'overall'],
       ['change', 'change'],
     ],
     default: 'series',
-    description: t('Defines how the color are attributed.'),
+    description: t('series: Treat each series independently; overall: All series use the same scale; change: Show changes compared to the first data point in each series'),
   },
 
   canvas_image_rendering: {
@@ -1205,6 +1206,7 @@ export const controls = {
 
   series_height: {
     type: 'SelectControl',
+    renderTrigger: true,
     freeForm: true,
     label: t('Series Height'),
     default: '25',
diff --git a/superset/assets/src/visualizations/HorizonChart.css b/superset/assets/src/visualizations/HorizonChart.css
new file mode 100644
index 0000000..3b78fdd
--- /dev/null
+++ b/superset/assets/src/visualizations/HorizonChart.css
@@ -0,0 +1,17 @@
+.horizon-chart {
+  overflow: auto;
+}
+
+.horizon-chart .horizon-row  {
+  border-bottom: solid 1px #ddd;
+  border-top: 0px;
+  padding: 0px;
+  margin: 0px;
+}
+
+.horizon-row span {
+  position: absolute;
+  color: #333;
+  font-size: 0.8em;
+  text-shadow: 1px 1px rgba(255, 255, 255, 0.75);
+}
diff --git a/superset/assets/src/visualizations/HorizonChart.jsx b/superset/assets/src/visualizations/HorizonChart.jsx
new file mode 100644
index 0000000..c17e982
--- /dev/null
+++ b/superset/assets/src/visualizations/HorizonChart.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import d3 from 'd3';
+import HorizonRow, { DEFAULT_COLORS } from './HorizonRow';
+import './HorizonChart.css';
+
+const propTypes = {
+  className: PropTypes.string,
+  width: PropTypes.number,
+  seriesHeight: PropTypes.number,
+  data: PropTypes.arrayOf(PropTypes.shape({
+    key: PropTypes.arrayOf(PropTypes.string),
+    values: PropTypes.arrayOf(PropTypes.shape({
+      y: PropTypes.number,
+    })),
+  })).isRequired,
+  // number of bands in each direction (positive / negative)
+  bands: PropTypes.number,
+  colors: PropTypes.arrayOf(PropTypes.string),
+  colorScale: PropTypes.string,
+  mode: PropTypes.string,
+  offsetX: PropTypes.number,
+};
+const defaultProps = {
+  className: '',
+  width: 800,
+  seriesHeight: 20,
+  bands: Math.floor(DEFAULT_COLORS.length / 2),
+  colors: DEFAULT_COLORS,
+  colorScale: 'series',
+  mode: 'offset',
+  offsetX: 0,
+};
+
+class HorizonChart extends React.PureComponent {
+  render() {
+    const {
+      className,
+      width,
+      data,
+      seriesHeight,
+      bands,
+      colors,
+      colorScale,
+      mode,
+      offsetX,
+    } = this.props;
+
+    let yDomain;
+    if (colorScale === 'overall') {
+      const allValues = data.reduce(
+        (acc, current) => acc.concat(current.values),
+        [],
+      );
+      yDomain = d3.extent(allValues, d => d.y);
+    }
+
+    return (
+      <div className={`horizon-chart ${className}`}>
+        {data.map(row => (
+          <HorizonRow
+            key={row.key}
+            width={width}
+            height={seriesHeight}
+            title={row.key[0]}
+            data={row.values}
+            bands={bands}
+            colors={colors}
+            colorScale={colorScale}
+            mode={mode}
+            offsetX={offsetX}
+            yDomain={yDomain}
+          />
+        ))}
+      </div>
+    );
+  }
+}
+
+HorizonChart.propTypes = propTypes;
+HorizonChart.defaultProps = defaultProps;
+
+function adaptor(slice, payload) {
+  const { selector, formData } = slice;
+  const element = document.querySelector(selector);
+  const {
+    horizon_color_scale: colorScale,
+    series_height: seriesHeight,
+  } = formData;
+
+  ReactDOM.render(
+    <HorizonChart
+      data={payload.data}
+      width={slice.width()}
+      seriesHeight={parseInt(seriesHeight, 10)}
+      colorScale={colorScale}
+    />,
+    element,
+  );
+}
+
+export default adaptor;
diff --git a/superset/assets/src/visualizations/HorizonRow.jsx b/superset/assets/src/visualizations/HorizonRow.jsx
new file mode 100644
index 0000000..fd96ad5
--- /dev/null
+++ b/superset/assets/src/visualizations/HorizonRow.jsx
@@ -0,0 +1,182 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import d3 from 'd3';
+
+export const DEFAULT_COLORS = [
+  '#313695',
+  '#4575b4',
+  '#74add1',
+  '#abd9e9',
+  '#fee090',
+  '#fdae61',
+  '#f46d43',
+  '#d73027',
+];
+
+const propTypes = {
+  className: PropTypes.string,
+  width: PropTypes.number,
+  height: PropTypes.number,
+  data: PropTypes.arrayOf(PropTypes.shape({
+    y: PropTypes.number,
+  })).isRequired,
+  bands: PropTypes.number,
+  colors: PropTypes.arrayOf(PropTypes.string),
+  colorScale: PropTypes.string,
+  mode: PropTypes.string,
+  offsetX: PropTypes.number,
+  title: PropTypes.string,
+  yDomain: PropTypes.arrayOf(PropTypes.number),
+};
+
+const defaultProps = {
+  className: '',
+  width: 800,
+  height: 20,
+  bands: DEFAULT_COLORS.length >> 1,
+  colors: DEFAULT_COLORS,
+  colorScale: 'series',
+  mode: 'offset',
+  offsetX: 0,
+  title: '',
+  yDomain: undefined,
+};
+
+class HorizonRow extends React.PureComponent {
+  componentDidMount() {
+    this.drawChart();
+  }
+
+  componentDidUpdate() {
+    this.drawChart();
+  }
+
+  componentWillUnmount() {
+    this.canvas = null;
+  }
+
+  drawChart() {
+    if (this.canvas) {
+      const {
+        data: rawData,
+        yDomain,
+        width,
+        height,
+        bands,
+        colors,
+        colorScale,
+        offsetX,
+        mode,
+      } = this.props;
+
+      const data = colorScale === 'change'
+        ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
+        : rawData;
+
+      const context = this.canvas.getContext('2d');
+      context.imageSmoothingEnabled = false;
+      context.clearRect(0, 0, width, height);
+      // Reset transform
+      context.setTransform(1, 0, 0, 1, 0, 0);
+      context.translate(0.5, 0.5);
+
+      const step = width / data.length;
+      // the data frame currently being shown:
+      const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
+      const endIndex = Math.floor(Math.min(data.length, startIndex + (width / step)));
+
+      // skip drawing if there's no data to be drawn
+      if (startIndex > data.length) {
+        return;
+      }
+
+      // Create y-scale
+      const [min, max] = yDomain || d3.extent(data, d => d.y);
+      const y = d3.scale.linear()
+        .domain([0, Math.max(-min, max)])
+        .range([0, height]);
+
+      // we are drawing positive & negative bands separately to avoid mutating canvas state
+      // http://www.html5rocks.com/en/tutorials/canvas/performance/
+      let hasNegative = false;
+      // draw positive bands
+      let value;
+      let bExtents;
+      for (let b = 0; b < bands; b += 1) {
+        context.fillStyle = colors[bands + b];
+
+        // Adjust the range based on the current band index.
+        bExtents = (b + 1 - bands) * height;
+        y.range([bands * height + bExtents, bExtents]);
+
+        // only the current data frame is being drawn i.e. what's visible:
+        for (let i = startIndex; i < endIndex; i++) {
+          value = data[i].y;
+          if (value <= 0) {
+            hasNegative = true;
+            continue;
+          }
+          if (value !== undefined) {
+            context.fillRect(
+              offsetX + i * step,
+              y(value),
+              step + 1,
+              y(0) - y(value),
+            );
+          }
+        }
+      }
+
+      // draw negative bands
+      if (hasNegative) {
+        // mirror the negative bands, by flipping the canvas
+        if (mode === 'offset') {
+          context.translate(0, height);
+          context.scale(1, -1);
+        }
+
+        for (let b = 0; b < bands; b++) {
+          context.fillStyle = colors[bands - b - 1];
+
+          // Adjust the range based on the current band index.
+          bExtents = (b + 1 - bands) * height;
+          y.range([bands * height + bExtents, bExtents]);
+
+          // only the current data frame is being drawn i.e. what's visible:
+          for (let ii = startIndex; ii < endIndex; ii++) {
+            value = data[ii].y;
+            if (value >= 0) {
+              continue;
+            }
+            context.fillRect(
+              offsetX + ii * step,
+              y(-value),
+              step + 1,
+              y(0) - y(-value),
+            );
+          }
+        }
+      }
+
+    }
+  }
+
+  render() {
+    const { className, title, width, height } = this.props;
+    return (
+      <div className={`horizon-row ${className}`}>
+        <span className="title">{title}</span>
+        <canvas
+          width={width}
+          height={height}
+          ref={(c) => { this.canvas = c; }}
+        />
+      </div>
+    );
+  }
+}
+
+HorizonRow.propTypes = propTypes;
+HorizonRow.defaultProps = defaultProps;
+
+export default HorizonRow;
diff --git a/superset/assets/src/visualizations/horizon.css b/superset/assets/src/visualizations/horizon.css
deleted file mode 100644
index 013b3e0..0000000
--- a/superset/assets/src/visualizations/horizon.css
+++ /dev/null
@@ -1,17 +0,0 @@
-.horizon .slice_container div.horizon  {
-    border-bottom: solid 1px #444;
-    border-top: 0px;
-    padding: 0px;
-    margin: 0px;
-}
-
-.horizon span {
-    left: 5;
-    position: absolute;
-    color: black;
-    text-shadow: 1px 1px rgba(255, 255, 255, 0.75);
-}
-
-.horizon .slice_container {
-    overflow: auto;
-}
diff --git a/superset/assets/src/visualizations/horizon.js b/superset/assets/src/visualizations/horizon.js
deleted file mode 100644
index b676b95..0000000
--- a/superset/assets/src/visualizations/horizon.js
+++ /dev/null
@@ -1,227 +0,0 @@
-/* eslint-disable prefer-rest-params, no-param-reassign */
-// Copied and modified from
-// https://github.com/kmandov/d3-horizon-chart
-import d3 from 'd3';
-import './horizon.css';
-
-const horizonChart = function () {
-  let colors = [
-    '#313695',
-    '#4575b4',
-    '#74add1',
-    '#abd9e9',
-    '#fee090',
-    '#fdae61',
-    '#f46d43',
-    '#d73027',
-  ];
-  let height = 30;
-  const y = d3.scale.linear().range([0, height]);
-  let bands = colors.length >> 1;  // number of bands in each direction (positive / negative)
-  let width = 1000;
-  let offsetX = 0;
-  let spacing = 0;
-  let mode = 'offset';
-  let axis;
-  let title;
-  let extent; // the extent is derived from the data, unless explicitly set via .extent([min, max])
-  let x;
-  let canvas;
-
-  function my(data) {
-    const horizon = d3.select(this);
-    const step = width / data.length;
-
-    horizon.append('span')
-    .attr('class', 'title')
-    .text(title);
-
-    horizon.append('span')
-    .attr('class', 'value');
-
-    canvas = horizon.append('canvas');
-
-    canvas
-    .attr('width', width)
-    .attr('height', height);
-
-    const context = canvas.node().getContext('2d');
-    context.imageSmoothingEnabled = false;
-
-    // update the y scale, based on the data extents
-    const ext = extent || d3.extent(data, d => d.y);
-
-    const max = Math.max(-ext[0], ext[1]);
-    y.domain([0, max]);
-
-    // x = d3.scaleTime().domain[];
-    axis = d3.svg.axis(x).ticks(5);
-
-    context.clearRect(0, 0, width, height);
-    // context.translate(0.5, 0.5);
-
-    // the data frame currently being shown:
-    const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
-    const endIndex = Math.floor(Math.min(data.length, startIndex + (width / step)));
-
-    // skip drawing if there's no data to be drawn
-    if (startIndex > data.length) {
-      return;
-    }
-
-    // we are drawing positive & negative bands separately to avoid mutating canvas state
-    // http://www.html5rocks.com/en/tutorials/canvas/performance/
-    let negative = false;
-    // draw positive bands
-    let value;
-    let bExtents;
-    for (let b = 0; b < bands; b += 1) {
-      context.fillStyle = colors[bands + b];
-
-      // Adjust the range based on the current band index.
-      bExtents = (b + 1 - bands) * height;
-      y.range([bands * height + bExtents, bExtents]);
-
-      // only the current data frame is being drawn i.e. what's visible:
-      for (let i = startIndex; i < endIndex; i++) {
-        value = data[i].y;
-        if (value <= 0) { negative = true; continue; }
-        if (value === undefined) {
-          continue;
-        }
-        context.fillRect(offsetX + i * step, y(value), step + 1, y(0) - y(value));
-      }
-    }
-
-    // draw negative bands
-    if (negative) {
-      // mirror the negative bands, by flipping the canvas
-      if (mode === 'offset') {
-        context.translate(0, height);
-        context.scale(1, -1);
-      }
-
-      for (let b = 0; b < bands; b++) {
-        context.fillStyle = colors[bands - b - 1];
-
-        // Adjust the range based on the current band index.
-        bExtents = (b + 1 - bands) * height;
-        y.range([bands * height + bExtents, bExtents]);
-
-        // only the current data frame is being drawn i.e. what's visible:
-        for (let ii = startIndex; ii < endIndex; ii++) {
-          value = data[ii].y;
-          if (value >= 0) {
-            continue;
-          }
-          context.fillRect(offsetX + ii * step, y(-value), step + 1, y(0) - y(-value));
-        }
-      }
-    }
-  }
-
-  my.axis = function (_) {
-    if (!arguments.length) { return axis; }
-    axis = _;
-    return my;
-  };
-
-  my.title = function (_) {
-    if (!arguments.length) { return title; }
-    title = _;
-    return my;
-  };
-
-  my.canvas = function (_) {
-    if (!arguments.length) { return canvas; }
-    canvas = _;
-    return my;
-  };
-
-  // Array of colors representing the number of bands
-  my.colors = function (_) {
-    if (!arguments.length) {
-      return colors;
-    }
-    colors = _;
-
-    // update the number of bands
-    bands = colors.length >> 1;
-    return my;
-  };
-
-  my.height = function (_) {
-    if (!arguments.length) { return height; }
-    height = _;
-    return my;
-  };
-
-  my.width = function (_) {
-    if (!arguments.length) { return width; }
-    width = _;
-    return my;
-  };
-
-  my.spacing = function (_) {
-    if (!arguments.length) { return spacing; }
-    spacing = _;
-    return my;
-  };
-
-  // mirror or offset
-  my.mode = function (_) {
-    if (!arguments.length) { return mode; }
-    mode = _;
-    return my;
-  };
-
-  my.extent = function (_) {
-    if (!arguments.length) { return extent; }
-    extent = _;
-    return my;
-  };
-
-  my.offsetX = function (_) {
-    if (!arguments.length) { return offsetX; }
-    offsetX = _;
-    return my;
-  };
-
-  return my;
-};
-
-function horizonViz(slice, payload) {
-  const fd = slice.formData;
-  const div = d3.select(slice.selector);
-  div.selectAll('*').remove();
-  let extent;
-  if (fd.horizon_color_scale === 'overall') {
-    let allValues = [];
-    payload.data.forEach(function (d) {
-      allValues = allValues.concat(d.values);
-    });
-    extent = d3.extent(allValues, d => d.y);
-  } else if (fd.horizon_color_scale === 'change') {
-    payload.data.forEach(function (series) {
-      const t0y = series.values[0].y;  // value at time 0
-      series.values = series.values.map(d =>
-        Object.assign({}, d, { y: d.y - t0y }),
-      );
-    });
-  }
-  div.selectAll('.horizon')
-  .data(payload.data)
-  .enter()
-  .append('div')
-  .attr('class', 'horizon')
-  .each(function (d, i) {
-    horizonChart()
-    .height(fd.series_height)
-    .width(slice.width())
-    .extent(extent)
-    .title(d.key)
-    .call(this, d.values, i);
-  });
-}
-
-module.exports = horizonViz;
diff --git a/superset/assets/src/visualizations/index.js b/superset/assets/src/visualizations/index.js
index 098079e..df24b67 100644
--- a/superset/assets/src/visualizations/index.js
+++ b/superset/assets/src/visualizations/index.js
@@ -83,7 +83,7 @@ const vizMap = {
   [VIZ_TYPES.heatmap]: () => loadVis(import(/* webpackChunkName: "heatmap" */ './heatmap.js')),
   [VIZ_TYPES.histogram]: () =>
     loadVis(import(/* webpackChunkName: "histogram" */ './histogram.js')),
-  [VIZ_TYPES.horizon]: () => loadVis(import(/* webpackChunkName: "horizon" */ './horizon.js')),
+  [VIZ_TYPES.horizon]: () => loadVis(import(/* webpackChunkName: "horizon" */ './HorizonChart.jsx')),
   [VIZ_TYPES.iframe]: () => loadVis(import(/* webpackChunkName: "iframe" */ './iframe.js')),
   [VIZ_TYPES.line]: loadNvd3,
   [VIZ_TYPES.line_multi]: () =>