diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx index 68fcb05c29..d2be8b02b3 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 0000000000..3b78fdd8ac --- /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 0000000000..c17e98266a --- /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 0000000000..fd96ad5f80 --- /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} + { this.canvas = c; }} + /> +
+ ); + } +} + +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 013b3e02bd..0000000000 --- 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 b676b95446..0000000000 --- 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 098079ea50..df24b67158 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]: () =>