<link href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css" rel="stylesheet" id="bootstrap-css">
<script src="//netdna.bootstrapcdn.com/bootstrap/3.0.0/js/bootstrap.min.js"></script>
<script src="//code.jquery.com/jquery-1.11.1.min.js"></script>
<!------ Include the above in your HEAD tag ---------->
<!DOCTYPE html><html lang='en' class=''>
<head><script src='//production-assets.codepen.io/assets/editor/live/console_runner-079c09a0e3b9ff743e39ee2d5637b9216b3545af0de366d4b9aad9dc87e26bfd.js'></script><script src='//production-assets.codepen.io/assets/editor/live/events_runner-73716630c22bbc8cff4bd0f07b135f00a0bdc5d14629260c3ec49e5606f98fdd.js'></script><script src='//production-assets.codepen.io/assets/editor/live/css_live_reload_init-2c0dc5167d60a5af3ee189d570b1835129687ea2a61bee3513dee3a50c115a77.js'></script><meta charset='UTF-8'><meta name="robots" content="noindex"><link rel="shortcut icon" type="image/x-icon" href="//production-assets.codepen.io/assets/favicon/favicon-8ea04875e70c4b0bb41da869e81236e54394d63638a1ef12fa558a4a835f1164.ico" /><link rel="mask-icon" type="" href="//production-assets.codepen.io/assets/favicon/logo-pin-f2d2b6d2c61838f7e76325261b7195c27224080bc099486ddd6dccb469b8e8e6.svg" color="#111" /><link rel="canonical" href="https://codepen.io/elijahmanor/pen/yyKMBa?depth=everything&order=popularity&page=63&q=react&show_forks=false" />
<link rel='stylesheet prefetch' href='https://cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css'><link rel='stylesheet prefetch' href='//cdnjs.cloudflare.com/ajax/libs/jasmine/2.0.0/jasmine.css'>
<style class="cp-pen-styles">.DonutChart {
width: 12.25rem;
margin: 0.5em;
}
.DonutChart-range {
display: block;
}
.DonutChart-path:nth-child(1) {
fill: #B52D43;
}
.DonutChart-path:nth-child(2) {
fill: #620CAC;
}
.DonutChart-path:nth-child(3) {
fill: #F83E5B;
}
.DonutChart-path:nth-child(4) {
fill: #7AC36A;
}
.DonutChart-path:nth-child(5) {
fill: #5A9BD4;
}
.DonutChart-path:nth-child(6) {
fill: #FAA75B;
}
.DonutChart-path:nth-child(7) {
fill: #A368D5;
}
.DonutChart-path:nth-child(8) {
fill: #CE7058;
}
.DonutChart-path:nth-child(9) {
fill: #D77FB4;
}
.DonutChart-path.DonutChart-path--empty {
fill: #CCC;
}
.DonutChart-path.DonutChart-path--spent {
fill: #8dc241;
}
.DonutChart-path.DonutChart-path--remaining {
fill: #5AA1CC;
}
.DonutChart-label, DonutChart-value {
display: inline-block;
text-align: center;
width: 100%;
}
.DonutChart-label {
fill: #777;
font-size: 2rem;
line-height: 2.5rem;
}
.DonutChart-value {
font-size: 2.5rem;
line-height: 2rem;
font-weight: bold;
}
#app {
margin: 1em;
}
.Message {
width: 12rem;
height: 12rem;
margin: 0.5em;
padding: 1em;
background-color: papayawhip;
border-radius: 50%;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
border: 1px solid black;
}
.DonutChart, .Message {
float: left;
}
#output, .html-reporter {
clear: both !important;
}
html {
box-sizing: border-box;
}
*, *:before, *:after {
box-sizing: inherit;
}
</style></head><body>
<div id="output"></div>
<script src='//production-assets.codepen.io/assets/common/stopExecutionOnTimeout-b2a7b3fe212eaa732349046d8416e00a9dec26eb7fd347590fbced3ab38af52e.js'></script><script src='https://codepen.io/elijahmanor/pen/XJEMxY.js'></script><script src='https://fb.me/react-with-addons-0.12.2.js'></script><script src='https://fb.me/JSXTransformer-0.12.2.js'></script><script src='//cdnjs.cloudflare.com/ajax/libs/jasmine/2.0.0/jasmine.js'></script><script src='//cdnjs.cloudflare.com/ajax/libs/jasmine/2.0.0/jasmine-html.js'></script><script src='//cdnjs.cloudflare.com/ajax/libs/jasmine/2.0.0/boot.js'></script><script src='//cdnjs.cloudflare.com/ajax/libs/chance/0.5.6/chance.js'></script>
<script >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,
startAngle: React.PropTypes.number,
stickyAngle: React.PropTypes.number, // stickyAngle
series: React.PropTypes.oneOfType([
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: null,
startAngle: 0,
defaultLabel: 'Income',
defaultValue: '$0',
onSelected: function(item) {},
series: []
};
},
render() {
var { width, height } = this.props;
return <svg className="DonutChart"
viewBox={`0 0 ${width} ${height}`}
onClick={this.handleClick}>
{this.renderPaths()}
{this.renderText()}
</svg>;
},
handleClick(e) {
e.nativeEvent.stopImmediatePropagation();
},
renderPaths() {
var total = parseFloat(this.props.total);
var size = this.props.series.reduce((memo, item) => memo + item.data, 0);
var startAngle = parseFloat(this.props.startAngle);
var stickyAngle = parseFloat(this.props.stickyAngle);
if (!isNaN(stickyAngle) && size !== total) {
//alert(stickyAngle - (size / total * 360 / 2))
startAngle = stickyAngle - (size / total * 360 / 2);
}
var series = this.props.series.map(item => {
var path = item.selected ? this.renderSelectedPath(item, total, startAngle) :
this.renderPath(item, total, startAngle);
startAngle += item.data / total * 360;
return path;
});
if (isNaN(total)) {
total = 100;
series.push(this.renderEmptyPath({ data: total }, total, startAngle));
} else if (size < total) {
series.push(this.renderEmptyPath({ data: total - size }, total, startAngle));
}
return series;
},
getTextEllipsis(text, maxWidth) {
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
var svgNS = svg.namespaceURI;
var newText = document.createElementNS(svgNS, "text");
var textNode = document.createTextNode('...');
var ellipsisWidth, textWidth;
if (!text) { return; }
newText.appendChild(textNode);
svg.appendChild(newText);
document.body.appendChild(svg);
ellipsisWidth = newText.getSubStringLength(0, '...'.length);
textNode.textContent = text;
textWidth = newText.getSubStringLength(0, text.length);
if (textWidth > maxWidth) {
while (textWidth + ellipsisWidth > maxWidth) {
text = text.slice(0, -1);
textNode.textContent = text + '...';
textWidth = newText.getSubStringLength(0, text.length);
}
}
svg.remove();
return textNode.textContent;
},
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="DonutChart-label" x="50%" y="45%" text-align="middle">
<tspan textAnchor="middle">{this.getTextEllipsis(label, 125)}</tspan>
<title>{label}</title>
</text>
<text className="DonutChart-value" x="50%" y="60%" text-align="middle">
<tspan textAnchor="middle">{value}</tspan>
<title>{value}</title>
</text>
</g>;
},
renderPath(item, total, startAngle) {
var {className, props} = item;
var classes = { 'DonutChart-path': true };
var d = this.getPathData(item.data, total, startAngle, this.props.width, this.props.innerRadius, this.props.outerRadius);
if (className) { classes[className] = true; }
return <path onClick={this.handleSelect.bind(this, item)} onMouseEnter={this.handleSelect.bind(this, item)} className={classSet(classes)} {...props} d={d}></path>;
},
renderSelectedPath(item, total, startAngle) {
var {className, props} = item;
var classes = { 'DonutChart-path': true, 'DonutChart-path--selected': true };
var d = this.getPathData(item.data, 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, total, startAngle) {
var {className, props} = item;
var classes = { 'DonutChart-path': true, 'DonutChart-path--empty': true };
var d = this.getPathData(item.data, 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 largeArcFlagOuter = activeAngle > 180 ? '1 1' : '0 1';
var largeArcFlagInner = activeAngle > 180 ? '1 0' : '0 0';
var half = width / 2;
var outerCoords = this.getCoordinates(half, outerRadius, startAngle, endAngle);
var innerCoords = this.getCoordinates(half, innerRadius, startAngle, endAngle);
return `M${outerCoords.x1},${outerCoords.y1}
${this.getArc(width, outerRadius, largeArcFlagOuter, outerCoords.x2, outerCoords.y2)}
L${innerCoords.x2},${innerCoords.y2}
${this.getArc(width, innerRadius, largeArcFlagInner, innerCoords.x1, innerCoords.y1)} z`;
},
toFixed(number, decimalPlaces) {
decimalPlaces = decimalPlaces || 2;
return (Math.floor(number * 100) / 100).toFixed(decimalPlaces);
},
getCoordinates(half, radius, startAngle, endAngle) {
var x1 = this.toFixed(half + half * radius * Math.cos(Math.PI * startAngle / 180));
var y1 = this.toFixed(half + half * radius * Math.sin(Math.PI * startAngle / 180));
var x2 = this.toFixed(half + half * radius * Math.cos(Math.PI * endAngle / 180));
var y2 = this.toFixed(half + half * radius * Math.sin(Math.PI * endAngle / 180));
return { x1, y1, x2, y2 };
},
getArc(width, radius, largeArcFlag, x, y) {
var z = this.toFixed(width / 2 * radius);
return `A${z},${z} 0 ${largeArcFlag} ${this.toFixed(x)},${this.toFixed(y)}`;
},
handleSelect(item, e) {
this.props.onSelected(item);
}
});
var myChance = new Chance(Date.now());
var App = React.createClass({
getInitialState() {
return {
showExamples: true,
chartWidth: 12.25,
defaultValue: '$6,282.32',
total: 6282.32,
series: [
{ label: 'Housing', value: '$1,208.84', data: 1208.84, selected: false },
{ label: 'Food', value: '$198.51', data: 198.51, selected: false },
{ label: 'Insurance', value: '$508.62', data: 508.62, selected: false },
{ label: 'Giving', value: '$628.23', data: 628.23, selected: false },
{ label: 'Transportation', value: '$1,208.84', data: 1208.84, selected: false },
{ label: 'Lifestyle', value: '$500.00', data: 500.00, selected: false },
{ label: 'Debt', value: '$2,029.28', data: 2029.28, selected: false }
]
};
},
componentDidMount: function() {
document.addEventListener("click", this.handleDocumentClick);
},
componentWillUnmount: function() {
document.removeEventListener("click", this.handleDocumentClick);
},
handleDocumentClick(e) {
this.setState({
series: this.state.series.map(i => {
i.selected = false;
return i;
}
)});
},
handleSelected(item, e) {
var series = this.state.series.map(i => {
i.selected = i.label === item.label;
return i;
});
this.setState({ series });
},
handleGo() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
} else {
this.timeoutId = setInterval(this.setRandomChartData, 500);
}
},
setRandomChartData() {
var groupNames = chance.pick(
['Housing', 'Food', 'Insurance', 'Giving', 'Transportation', 'Lifestyle'],
chance.integer({min: 0, max: 5})
);
var series = [], total = 0;
groupNames.forEach(groupName => {
var value = chance.floating({ min: 100, max: 1000 });
series.push({
label: groupName,
value: `$${value.toFixed(2)}`,
data: value,
selected: false
});
});
total = series.reduce((memo, g) => memo + g.data, 0);
if (series.length && chance.bool()) {
series = series.slice(0, series.length-1);
}
if (series.length && chance.bool()) {
series[chance.integer({min: 0, max: series.length-1})].selected = true;
}
this.setState({ total, series });
},
handleToggleExamples(e) {
e.preventDefault();
this.setState({ showExamples: !this.state.showExamples });
},
handleChange(e) {
e.nativeEvent.preventDefault();
e.nativeEvent.stopPropagation();
var nodes = [].slice.call(document.querySelectorAll('.DonutChart'));
nodes = nodes.concat([].slice.call(document.querySelectorAll('.Message')));
nodes.forEach(function(node) {
node.style.width = e.target.value + 'rem';
node.style.height = e.target.value + 'rem';
});
this.setState({ chartWidth: e.target.value });
},
render() {
var charts = [];
charts.push(<DonutChart defaultLabel="Income" defaultValue="$0" />);
charts.push(<DonutChart defaultLabel="Income" defaultValue="$6,282.32" total="6282.32" />);
charts.push(<DonutChart defaultLabel="Income" defaultValue="$6,282.32" total="6282.32" series={[
{ label: 'Housing', value: '$1,208.84', data: 1208.84 },
]} />);
charts.push(<DonutChart defaultLabel="Income" defaultValue="$6,282.32" total="6282.32" series={[
{ label: 'Housing', value: '$1,208.84', data: 1208.84 },
{ label: 'Food', value: '$198.51', data: 198.51 }
]} />);
charts.push(<DonutChart defaultLabel="Income" defaultValue="$6,282.32" total="6282.32" series={[
{ label: 'Housing', value: '$1,208.84', data: 1208.84 },
{ label: 'Food', value: '$198.51', data: 198.51 },
{ label: 'Insurance', value: '$508.62', data: 508.62 }
]} />);
charts.push(<DonutChart defaultLabel="Income" defaultValue="$6,282.32" total="6282.32" series={[
{ label: 'Housing', value: '$1,208.84', data: 1208.84, selected: true },
{ label: 'Food', value: '$198.51', data: 198.51 },
{ label: 'Insurance', value: '$508.62', data: 508.62 },
{ label: 'Giving', value: '$628.23', data: 628.23 },
{ label: 'Transportation', value: '$1,208.84', data: 1208.84 },
{ label: 'Lifestyle', value: '$500.00', data: 500.00 },
{ label: 'Debt', value: '$2,029.28', data: 2029.28 }
]} />);
charts.push(<DonutChart defaultLabel="Spent" defaultValue="$1,971.84" total="6282.32" startAngle="-90" series={[
{ label: 'Housing', value: '$1,208.84', data: 1208.84, className: 'DonutChart-path--spent' },
{ label: 'Food', value: '$198.51', data: 198.51, className: 'DonutChart-path--spent' },
{ label: 'Insurance', value: '$508.62', data: 508.62, className: 'DonutChart-path--spent' }
]} />);
charts.push(<DonutChart defaultLabel="Spent" defaultValue="$1,971.84" total="6282.32" startAngle="-90" series={[
{ label: 'Housing', value: '$1,208.84', data: 1208.84, className: 'DonutChart-path--spent' },
{ label: 'Food', value: '$198.51', data: 198.51, selected: true, className: 'DonutChart-path--spent' },
{ label: 'Insurance', value: '$508.62', data: 508.62, className: 'DonutChart-path--spent' }
]} />);
charts.push(<DonutChart defaultLabel="Remaining" defaultValue="$5,452.68" total="6282.32" series={[
{ label: 'Remaining', value: '$5,452.68', data: 5452.68, className: 'DonutChart-path--remaining' }
]} />);
charts.push(<DonutChart defaultLabel="Really Long Default Label" defaultValue="$5,452.68" total="6282.32" series={[
{ label: 'Remaining', value: '$5,452.68', data: 5452.68, className: 'DonutChart-path--remaining' }
]} />);
charts.push(<DonutChart defaultLabel="Sticky: -45" defaultValue="$5,452.68" total="6282.32" stickyAngle="-45" series={[
{ label: 'Sticky', value: '$5,452.68', data: 5452.68 }
]} />);
charts.push(<DonutChart defaultLabel="Sticky: 135" defaultValue="$800.00" total="6282.32" stickyAngle="135" series={[
{ label: 'Sticky', value: '$5,452.68', data: 800.00 }
]} />);
// charts = []; // todo - uncomment
return <div id="app" onClick={this.handleClick}>
<input className="DonutChart-range" type="range" min="10" max="30" value={this.state.chartWidth} step="1" onChange={this.handleChange} />
<DonutChart {...this.state} onSelected={this.handleSelected} />
<div className="Message">
<p>The DonutChart on the left is interactive</p>
<button onClick={this.handleGo}>GO</button>
</div>
{charts}
</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('DonutChart')).toBe(true);
});
it('should be a composite component', () => {
component = renderElem(props);
expect(TestUtils.isCompositeComponent(component)).toBe(true);
});
it('should not use a width/height attribute', () => {
component = renderElem(props);
element = component.getDOMNode();
expect(element.getAttribute('width')).toBeFalsy();
expect(element.getAttribute('height')).toBeFalsy();
});
it('should define a viewBox', () => {
component = renderElem(props);
element = component.getDOMNode();
expect(element.getAttribute('viewBox')).toBe('0 0 350 350');
});
});
describe('methods', () => {
describe('toFixed', () => {
it('should fix a number with no decimal values', () => {
component = renderElem(props);
expect(component.toFixed(100)).toBe('100.00');
});
it('should fix a number with 2 decimal values', () => {
component = renderElem(props);
expect(component.toFixed(100.12)).toBe('100.12');
});
it('should fix a number with multiple decimal values', () => {
component = renderElem(props);
expect(component.toFixed(100.123456789)).toBe('100.12');
});
});
describe('getTextEllipsis', () => {
it('should not truncate small strings', () => {
component = renderElem(props);
var smallString = 'small string';
var text = component.getTextEllipsis(smallString, 100);
expect(text).toBe(smallString);
});
it('should truncate long strings', () => {
component = renderElem(props);
var longString = 'this is a really long string';
var text = component.getTextEllipsis(longString, 100);
expect(text).toBe('this is a real...');
});
});
});
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);
});
it('should generate one empty path if a series is not provided', () => {
component = renderElem();
element = component.getDOMNode();
expect(element.querySelectorAll('.DonutChart-path--empty').length).toBe(1);
});
it('should generate one empty path if the total provided is NaN', () => {
component = renderElem({ total: null, series: [{ data: 1 }, { data: 2 }] });
element = component.getDOMNode();
expect(element.querySelectorAll('.DonutChart-path--empty').length).toBe(1);
});
it('should generate a selcted path if one of the series items is selected', () => {
component = renderElem({ total: 3, series: [{ data: 1 }, { data: 2, selected: true }] });
element = component.getDOMNode();
expect(element.querySelectorAll('.DonutChart-path--selected').length).toBe(1);
});
it('should apply an optional className if provided for each entry', () => {
component = renderElem({ total: 3, series: [{ data: 1 }, { data: 2, className: 'DonutChart-path--spent' }] });
element = component.getDOMNode();
expect(element.querySelectorAll('.DonutChart-path--spent').length).toBe(1);
});
});
});
//# sourceURL=pen.js
</script>
</body></html>