Skip to content

Commit

Permalink
[SIP-5] Repair and refactor Horizon Chart (apache#5690)
Browse files Browse the repository at this point in the history
* Migrate horizon chart to react

* remove unused code

* rename files

* update props

* enable renderTrigger

* fix canvas transform issue

* Address Chris' comment
  • Loading branch information
kristw authored and williaster committed Aug 27, 2018
1 parent f5cc983 commit d7f06cb
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 247 deletions.
6 changes: 4 additions & 2 deletions superset/assets/src/explore/controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -1205,6 +1206,7 @@ export const controls = {

series_height: {
type: 'SelectControl',
renderTrigger: true,
freeForm: true,
label: t('Series Height'),
default: '25',
Expand Down
17 changes: 17 additions & 0 deletions superset/assets/src/visualizations/HorizonChart.css
Original file line number Diff line number Diff line change
@@ -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);
}
103 changes: 103 additions & 0 deletions superset/assets/src/visualizations/HorizonChart.jsx
Original file line number Diff line number Diff line change
@@ -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;
182 changes: 182 additions & 0 deletions superset/assets/src/visualizations/HorizonRow.jsx
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 0 additions & 17 deletions superset/assets/src/visualizations/horizon.css

This file was deleted.

Loading

0 comments on commit d7f06cb

Please sign in to comment.