diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 68fcb05c29010..d2be8b02b3857 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 0000000000000..3b78fdd8ac0cc
--- /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 0000000000000..c17e98266af68
--- /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 (
+
+ {data.map(row => (
+
+ ))}
+
+ );
+ }
+}
+
+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(
+ ,
+ 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 0000000000000..fd96ad5f800f2
--- /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 (
+
+ {title}
+
+ );
+ }
+}
+
+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 013b3e02bd060..0000000000000
--- 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 b676b95446176..0000000000000
--- 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 098079ea500dd..df24b67158128 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]: () =>