Skip to content

Instantly share code, notes, and snippets.

@elijahmanor
Created February 20, 2015 06:30
Show Gist options
  • Save elijahmanor/34c5f5d4f6204f151295 to your computer and use it in GitHub Desktop.
Save elijahmanor/34c5f5d4f6204f151295 to your computer and use it in GitHub Desktop.
React Donut Chart SVG Component
<div id="output"></div>
var classSet = React.addons.classSet;
var DonutChart = React.createClass({
propTypes: {
height: React.PropTypes.number,
width: React.PropTypes.number,
outerRadius: React.PropTypes.number,
outerRadiusHover: React.PropTypes.number,
innerRadius: React.PropTypes.number,
innerRadiusHover: React.PropTypes.number,
emptyWidth: React.PropTypes.number,
total: React.PropTypes.number,
defaultLabel: React.PropTypes.string,
defaultValue: React.PropTypes.string,
series: React.PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.number),
React.PropTypes.arrayOf(React.PropTypes.shape({
data: React.PropTypes.number.isRequired,
className: React.PropTypes.string
}))
])
},
getDefaultProps() {
return {
height: 350,
width: 350,
outerRadius: 0.95,
outerRadiusHover: 1,
innerRadius: 0.85,
innerRadiusHover: 0.85,
emptyWidth: .06,
total: 0,
defaultLabel: 'Income',
defaultValue: '$2,543.45',
onSelected: function(item) {},
series: []
};
},
render() {
var { width, height } = this.props;
return <svg className="Chart Chart--donut" viewBox={`0 0 ${width} ${height}`}>
{this.renderPaths()}
{this.renderText()}
</svg>;
},
renderPaths() {
var total = this.props.total;
var size = this.props.series.reduce((memo, item) => memo + item.data, 0);
var angle = 0;
var series = this.props.series.map(item => {
var path = item.selected ? this.renderSelectedPath(item, angle) : this.renderPath(item, angle);
angle += (item.data / total) * 360;
return path;
});
if (size < total) {
series.push(this.renderEmptyPath({ data: total - size }, angle));
}
return series;
},
renderText() {
var series = this.props.series.filter(item => item.selected);
var selected = series.length ? series[0] : null;
var label = selected ? selected.label : this.props.defaultLabel;
var value = selected ? selected.value : this.props.defaultValue;
return <g>
<text className="Chart-text" x="50%" y="50%" text-align="middle">
<tspan dx="0" textAnchor="middle">{label}</tspan>
</text>
<text className="Chart-text" x="50%" y="50%" text-align="middle">
<tspan dy="25" textAnchor="middle">{value}</tspan>
</text>
</g>;
},
renderPath(item, startAngle) {
var {className, props} = item;
var classes = { 'Chart-path': true };
var d = this.getPathData(item.data, this.props.total, startAngle, this.props.width, this.props.innerRadius, this.props.outerRadius);
if (className) { classes[className] = true; }
return <path onClick={this.handleClick.bind(this, item)} className={classSet(classes)} {...props} d={d}></path>;
},
renderSelectedPath(item, startAngle) {
var {className, props} = item;
var classes = { 'Chart-path': true, 'Chart-path--selected': true };
var d = this.getPathData(item.data, this.props.total, startAngle, this.props.width, this.props.innerRadiusHover, this.props.outerRadiusHover);
if (className) { classes[className] = true; }
return <path className={classSet(classes)} {...props} d={d}></path>;
},
renderEmptyPath(item, startAngle) {
var {className, props} = item;
var classes = { 'Chart-path': true, 'Chart-path--empty': true };
var d = this.getPathData(item.data, this.props.total, startAngle, this.props.width, this.props.innerRadius + 0.03, this.props.outerRadius - 0.03);
if (className) { classes[className] = true; }
return <path className={classSet(classes)} {...props} d={d}></path>;
},
getPathData(data, total, startAngle, width, innerRadius, outerRadius) {
var activeAngle = data / total * 360;
var endAngle = startAngle + activeAngle;
var largeArcFlagSweepFlagOuter = activeAngle > 180 ? '1 1' : '0 1';
var largeArcFlagSweepFlagInner = activeAngle > 180 ? '1 0' : '0 0';
var half = width / 2;
var x1 = half + half * outerRadius * Math.cos(Math.PI * startAngle / 180);
var y1 = half + half * outerRadius * Math.sin(Math.PI * startAngle / 180);
var x2 = half + half * outerRadius * Math.cos(Math.PI * endAngle / 180);
var y2 = half + half * outerRadius * Math.sin(Math.PI * endAngle / 180);
var x3 = half + half * innerRadius * Math.cos(Math.PI * startAngle / 180);
var y3 = half + half * innerRadius * Math.sin(Math.PI * startAngle / 180);
var x4 = half + half * innerRadius * Math.cos(Math.PI * endAngle / 180);
var y4 = half + half * innerRadius * Math.sin(Math.PI * endAngle / 180);
return `M${x1},${y1} ${this.getArc(width, outerRadius, largeArcFlagSweepFlagOuter, x2, y2)} L${x4},${y4} ${this.getArc(width, innerRadius, largeArcFlagSweepFlagInner, x3, y3)} z`;
},
getArc(canvasSide, radius, largeArcFlagSweepFlag, x, y) {
var z = canvasSide / 2 * radius;
return `A${z},${z} 0 ${largeArcFlagSweepFlag} ${x},${y}`;
},
handleClick(item, e) {
this.props.onSelected(item);
}
});
var Money = React.createClass({
propTypes: {
value: React.PropTypes.number,
},
getDefaultProps() {
return {
value: 0
};
},
render() {
var value = parseFloat(this.props.value);
return <span>${value.toFixed(2)}</span>
}
});
var App = React.createClass({
getInitialState() {
return {
total: 100,
series: [
{ label: 'Food', value: '5%', data: 5, selected: false, className: 'Chart-path--spent' },
{ label: 'House', value: '10%', data: 10, selected: true },
{ label: 'Entertainment', value: '15%', data: 15, selected: false },
{ label: 'Auto', value: '20%', data: 20, selected: false },
{ label: 'Clothes', value: '25%', data: 25, selected: false }
]
};
},
handleSelected(item) {
var series = this.state.series.map(i => {
i.selected = i.label === item.label;
return i;
});
this.setState({ series });
},
render() {
return <div id="app">
<DonutChart {...this.state} onSelected={this.handleSelected} />
</div>;
}
});
React.render(<App />, document.querySelector('#output'));
// Unit Tests
var TestUtils = React.addons.TestUtils;
var renderElem = (props) => {
var component = new DonutChart(props);
return TestUtils.renderIntoDocument(component);
};
describe('DonutChart', () => {
var component, props, element;
beforeEach(() => {
props = {};
});
describe('rendering', () => {
it('should build the component', () => {
component = renderElem(props);
element = component.getDOMNode();
expect(component).toBeTruthy();
expect(element.classList.contains('Chart--donut')).toBe(true);
});
it('should be a composite component', () => {
component = renderElem(props);
expect(TestUtils.isCompositeComponent(component)).toBe(true);
});
});
describe('props', () => {
it('should generate a path for each series if sum is equal to total', () => {
props.total = 100;
props.series = [{ data: 50 }, { data: 50 }];
component = renderElem(props);
element = component.getDOMNode();
expect(element.querySelectorAll('path').length).toBe(2);
});
it('should generate an extra path is series sum is not equal to total', () => {
props.total = 100;
props.series = [{ data: 1 }, { data: 2 }];
component = renderElem(props);
element = component.getDOMNode();
expect(element.querySelectorAll('path').length).toBe(3);
});
xit('should apply an optional className if provided for each entry')
});
});
$colors: #B52D43, #620CAC, #F83E5B, #7AC36A, #5A9BD4, #FAA75B, #A368D5, #CE7058, #D77FB4;
.Chart {}
.Chart-path {
@for $i from 1 through length($colors) {
&:nth-child(#{$i}) {
fill: nth($colors, $i);
}
}
&.Chart-path--empty {
fill: #CCC;
}
&.Chart-path--spent {
fill: #D77FB4;
}
&.Chart-path--remaining {
fill: #144A90;
}
}
.Chart-text {
display:inline-block;
text-align:center;
width:100%;
}
#app {
margin: 1em;
background-color: transparent;
width: 20rem;
height: 20rem;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment