diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..bd20dc179 --- /dev/null +++ b/.babelrc @@ -0,0 +1,6 @@ +{ + "presets": [ + "es2015", + "react" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0ed1e792c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +logs +*.log +node_modules +.idea +lib +npm-debug.log* diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..faa45389e --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) Raphaël Benitte + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..f05174f7f --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# nivo + +[![License][license-image]][license-url] +[![Travis CI][travis-image]][travis-url] +[![NPM version][npm-image]][npm-url] +[![Dependencies][gemnasium-image]][gemnasium-url] + +[license-image]: https://img.shields.io/github/license/nivo/mozaik.svg?style=flat-square +[license-url]: https://github.com/plouc/nivo/blob/master/LICENSE.md +[npm-image]: https://img.shields.io/npm/v/nivo.svg?style=flat-square +[npm-url]: https://www.npmjs.com/package/nivo +[travis-image]: https://img.shields.io/travis/plouc/nivo.svg?style=flat-square +[travis-url]: https://travis-ci.org/plouc/nivo +[gemnasium-image]: https://img.shields.io/gemnasium/plouc/nivo.svg?style=flat-square +[gemnasium-url]: https://gemnasium.com/plouc/nivo diff --git a/package.json b/package.json new file mode 100644 index 000000000..854ddf601 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "nivo", + "version": "0.0.0", + "author": { + "name": "Raphaël Benitte", + "url": "https://github.com/plouc" + }, + "keywords": [], + "dependencies": { + "d3": "3.5.16", + "invariant": "2.2.1", + "lodash": "4.11.1", + "react-dimensions": "0.1.1" + }, + "devDependencies": { + "babel-preset-es2015": "6.6.0", + "babel-preset-react": "6.5.0", + "react": "^0.13.3" + }, + "peerDependencies": { + "react": "^0.13.3" + }, + "main": "src/index.js", + "scripts": {} +} diff --git a/src/ArcUtils.js b/src/ArcUtils.js new file mode 100644 index 000000000..4387113c5 --- /dev/null +++ b/src/ArcUtils.js @@ -0,0 +1,86 @@ +import d3 from 'd3'; + + +export const degreesToRadians = degrees => degrees * Math.PI / 180; + +export const radiansToDegrees = radians => 180 * radians / Math.PI; + + +/** + * Try to get a neighbor arc, otherwise, returns null. + * + * @param {Number} i + * @param {String} keyProp + * @param {Array} prevData + * @param {Array} newData + * @returns {{startAngle: *, endAngle: *}} + */ +export const findNeighbor = (i, keyProp, prevData, newData) => { + const preceding = findPreceding(i, keyProp, prevData, newData); + if (preceding) { + return { + startAngle: preceding.endAngle, + endAngle: preceding.endAngle + }; + } + + const following = findFollowing(i, keyProp, prevData, newData); + if (following) { + return { + startAngle: following.startAngle, + endAngle: following.startAngle + }; + } + + return null; +}; + +/** + * Find the element in prevData that joins the highest preceding element in newData. + * + * @param {Number} i + * @param {String} keyProp + * @param {Array} prevData + * @param {Array} newData + * @returns {*} + */ +export const findPreceding = (i, keyProp, prevData, newData) => { + const m = prevData.length; + + while (--i >= 0) { + let k = newData[i].data[keyProp]; + + for (let j = 0; j < m; ++j) { + if (prevData[j].data[keyProp] === k) { + return prevData[j]; + } + } + } +}; + +/** + * Find the element in prevData that joins the lowest following element in newData. + * + * @param {Number} i + * @param {String} keyProp + * @param {Array} prevData + * @param {Array} newData + * @returns {*} + */ +export const findFollowing = (i, keyProp, prevData, newData) => { + const n = newData.length; + const m = prevData.length; + + while (++i < n) { + let k = newData[i].data[keyProp]; + + for (let j = 0; j < m; ++j) { + if (prevData[j].data[keyProp] === k) { + return prevData[j]; + } + } + } +}; + + +export const midAngle = arc => arc.startAngle + (arc.endAngle - arc.startAngle) / 2; diff --git a/src/components/Chart.jsx b/src/components/Chart.jsx new file mode 100644 index 000000000..9ad4bf1e2 --- /dev/null +++ b/src/components/Chart.jsx @@ -0,0 +1,56 @@ +import React, { Component, PropTypes } from 'react'; +import Dimensions from 'react-dimensions'; +import _ from 'lodash'; + + +const defaultMargin = { + top: 0, + right: 0, + bottom: 0, + left: 0 +}; + +class Chart extends Component { + render() { + const { + containerWidth, + containerHeight, + children + } = this.props; + + const margin = _.assign({}, defaultMargin, this.props.margin); + + const width = containerWidth - margin.left - margin.right; + const height = containerHeight - margin.top - margin.bottom; + + return ( + + + {React.Children.map(children, child => ( + React.cloneElement(child, { width, height }) + ))} + + + ); + } +} + +Chart.displayName = 'Chart'; + +Chart.propTypes = { + containerWidth: PropTypes.number.isRequired, + containerHeight: PropTypes.number.isRequired, + margin: PropTypes.shape({ + top: PropTypes.number, + right: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number + }).isRequired +}; + +Chart.defaultProps = { + margin: {} +}; + + +export default Dimensions()(Chart); diff --git a/src/components/axes/AxisX.jsx b/src/components/axes/AxisX.jsx new file mode 100644 index 000000000..a8e329242 --- /dev/null +++ b/src/components/axes/AxisX.jsx @@ -0,0 +1,19 @@ +import React, { Component, PropTypes } from 'react'; +import d3 from 'd3'; + +class AxisX extends Component { + render() { + return null; + } +} + +AxisX.displayName = 'AxisX'; + +AxisX.propTypes = { +}; + +AxisX.defaultProps = { +}; + + +export default AxisX; diff --git a/src/components/axes/AxisY.jsx b/src/components/axes/AxisY.jsx new file mode 100644 index 000000000..9f8c320b8 --- /dev/null +++ b/src/components/axes/AxisY.jsx @@ -0,0 +1,61 @@ +import React, { Component, PropTypes } from 'react'; +import d3 from 'd3'; + +class AxisY extends Component { + shouldComponentUpdate(nextProps) { + const { + orient, + yScale, + tickMode, + tickPadding, + width, + transitionDuration, + transitionEasing + } = nextProps; + + const element = d3.select(React.findDOMNode(this)); + + const axis = d3.svg.axis() + .scale(yScale) + .tickPadding(tickPadding) + .orient(orient) + ; + + if (tickMode === 'grid') { + axis.tickSize(-width); + } + + element + .transition() + .duration(transitionDuration) + .ease(transitionEasing) + .call(axis) + ; + + return false; + } + + render() { + return ; + } +} + +AxisY.displayName = 'AxisY'; + +AxisY.propTypes = { + orient: PropTypes.oneOf(['left', 'right']).isRequired, + yScale: PropTypes.func.isRequired, + tickMode: PropTypes.oneOf(['normal', 'grid']).isRequired, + tickPadding: PropTypes.number.isRequired +}; + +AxisY.defaultProps = { + orient: 'left', + transitionDuration: 600, + transitionEasing: 'cubic-out', + tickMode: 'normal', + tickPadding: 3 +}; + + +export default AxisY; diff --git a/src/components/layouts/Pie.jsx b/src/components/layouts/Pie.jsx new file mode 100644 index 000000000..d813a7a53 --- /dev/null +++ b/src/components/layouts/Pie.jsx @@ -0,0 +1,152 @@ +import React, { Component, PropTypes } from 'react'; +import _ from 'lodash'; +import d3 from 'd3'; +import { degreesToRadians, findNeighbor } from '../../ArcUtils'; + + +class Pie extends Component { + shouldComponentUpdate(nextProps) { + const { + data, + width, height, + sort, + keyProp, valueProp, + startAngle, endAngle, padAngle, + innerRadius, + transitionDuration, transitionEasing, + } = nextProps; + + const element = d3.select(React.findDOMNode(this)); + const container = element.select('.chart__layout__pie__slices'); + + element.attr('transform', `translate(${width / 2}, ${height / 2})`); + + const pie = d3.layout.pie() + .sort(sort) + .value(d => d[valueProp]) + .startAngle(degreesToRadians(startAngle)) + .endAngle(degreesToRadians(endAngle)) + .padAngle(degreesToRadians(padAngle)) + ; + + const radius = Math.min(width / 2, height / 2); + const arc = d3.svg.arc() + .outerRadius(radius) + .innerRadius(radius * innerRadius) + ; + + element.select('.chart__layout__pie__outline').attr('d', arc({ + startAngle: degreesToRadians(startAngle), + endAngle: degreesToRadians(endAngle) + })); + + const color = d3.scale.category20(); + + let slices = container.selectAll('.chart__layout__pie__slice'); + const previousData = slices.data(); + const newData = pie(data.map((d, i) => { + if (!d.color) { + d.color = color(i); + } + + return d; + })); + + function arcTween(a) { + var i = d3.interpolate(this._current, a); + this._current = i(0); + return function (t) { + return arc(i(t)); + }; + } + + slices = slices.data(newData, d => d.data[keyProp]); + slices.enter().append('path') + .attr('class', 'chart__layout__pie__slice') + .style('fill', d => d.data.color) + .each(function (d, i) { + this._current = findNeighbor(i, keyProp, previousData, newData) || _.assign({}, d, { endAngle: d.startAngle }); + }) + ; + slices.exit() + .datum((d, i) => { + return findNeighbor(i, keyProp, newData, previousData) || d; + }) + .transition() + .duration(transitionDuration) + .ease(transitionEasing) + .attrTween('d', arcTween) + .remove() + ; + slices + .transition() + .duration(transitionDuration) + .ease(transitionEasing) + .attrTween('d', arcTween) + .style('fill', d => d.data.color) + ; + + this.legends.forEach(legend => { + legend({ element, pie, arc, keyProp, data: newData, radius }); + }); + + return false; + } + + componentWillMount() { + const { children } = this.props; + + const legends = []; + + React.Children.forEach(children, element => { + if (React.isValidElement(element)) { + if (element.type.createLegendsFromReactElement) { + legends.push(element.type.createLegendsFromReactElement(element)); + } + } + }); + + this.legends = legends; + } + + render() { + return ( + + + + + ); + } +} + +const { array, number, string, func } = PropTypes; + +Pie.propTypes = { + width: number.isRequired, + height: number.isRequired, + sort: func, + data: array.isRequired, + keyProp: string.isRequired, + valueProp: string.isRequired, + startAngle: number.isRequired, + endAngle: number.isRequired, + padAngle: number.isRequired, + transitionDuration: number.isRequired, + transitionEasing: string.isRequired, + innerRadius: number.isRequired +}; + +Pie.defaultProps = { + sort: null, + keyProp: 'label', + valueProp: 'value', + startAngle: 0, + endAngle: 360, + padAngle: 0, + transitionDuration: 1000, + transitionEasing: 'cubic-out', + innerRadius: 0 +}; + + +export default Pie; diff --git a/src/components/layouts/PieColumnLegends.jsx b/src/components/layouts/PieColumnLegends.jsx new file mode 100644 index 000000000..1bd770fc4 --- /dev/null +++ b/src/components/layouts/PieColumnLegends.jsx @@ -0,0 +1,92 @@ +import React, { Component, PropTypes } from 'react'; +import invariant from 'invariant'; +import d3 from 'd3'; +import { midAngle } from '../../ArcUtils'; + + +class PieColumnLegends extends Component { + static createLegendsFromReactElement(element) { + const { props } = element; + + return ({ element, arc, keyProp, pie, data, radius }) => { + + const labelFn = props.labelFn || (d => d.data[keyProp]); + + const outerArc = d3.svg.arc() + .innerRadius(radius + props.radiusOffset) + .outerRadius(radius + props.radiusOffset) + ; + + let lines = element.selectAll('.line').data(data, d => d.data[keyProp]); + lines.enter() + .append('polyline') + .attr('stroke', '#fff') + .attr('fill', 'none') + .attr('class', 'line') + ; + lines + .attr('points', d => { + const p0 = arc.centroid(d); + const p1 = outerArc.centroid(d); + const p2 = [0, p1[1]]; + + p2[0] = (radius + props.horizontalOffset) * (midAngle(d) < Math.PI ? 1 : -1); + + return [p0, p1, p2]; + }) + ; + lines.exit() + .remove() + ; + + let labels = element.selectAll('.column-label').data(data, d => d.data[keyProp]); + labels.enter() + .append('text') + .attr('fill', '#fff') + .attr('class', 'column-label') + ; + labels + .text(labelFn) + .attr('text-anchor', d => { + return midAngle(d) < Math.PI ? 'start' : 'end'; + }) + .attr('transform', d => { + const centroid = outerArc.centroid(d); + const position = [0, centroid[1]]; + + position[0] = (radius + props.horizontalOffset + props.textOffset) * (midAngle(d) < Math.PI ? 1 : -1); + + return `translate(${position[0]}, ${position[1]})`; + }) + ; + labels.exit() + .remove() + ; + }; + } + + render() { + invariant( + false, + ' element is for Pie configuration only and should not be rendered' + ); + } +} + +const { number, func } = PropTypes; + +PieColumnLegends.propTypes = { + labelFn: func, + radiusOffset: number.isRequired, + horizontalOffset: number.isRequired, + textOffset: number.isRequired +}; + +PieColumnLegends.defaultProps = { + radiusOffset: 16, + horizontalOffset: 30, + textOffset: 10 +}; + + +export default PieColumnLegends; diff --git a/src/components/layouts/PieRadialLegends.jsx b/src/components/layouts/PieRadialLegends.jsx new file mode 100644 index 000000000..6bdbc2dc6 --- /dev/null +++ b/src/components/layouts/PieRadialLegends.jsx @@ -0,0 +1,66 @@ +import React, { Component, PropTypes } from 'react'; +import invariant from 'invariant'; +import d3 from 'd3'; +import { midAngle, radiansToDegrees } from '../../ArcUtils'; + + +class PieRadialLegends extends Component { + static createLegendsFromReactElement(element) { + const { props } = element; + + return ({ element, arc, keyProp, pie, data, radius }) => { + + const labelFn = props.labelFn || (d => d.data[keyProp]); + + const outerArc = d3.svg.arc() + .innerRadius(radius + props.radiusOffset) + .outerRadius(radius + props.radiusOffset) + ; + + let labels = element.selectAll('.radial-label').data(data, d => d.data[keyProp]); + labels.enter() + .append('text') + .attr('fill', '#fff') + .attr('class', 'radial-label') + ; + labels + .text(labelFn) + .attr('text-anchor', d => { + return midAngle(d) < Math.PI ? 'start' : 'end'; + }) + .attr('transform', d => { + const centroid = outerArc.centroid(d); + const angle = midAngle(d); + + const angleOffset = angle < Math.PI ? -90 : 90; + + return `translate(${centroid[0]}, ${centroid[1]}) rotate(${radiansToDegrees(angle) + angleOffset}, 0, 0)`; + }) + ; + labels.exit() + .remove() + ; + }; + } + + render() { + invariant( + false, + ' element is for Pie configuration only and should not be rendered' + ); + } +} + +const { number, func } = PropTypes; + +PieRadialLegends.propTypes = { + labelFn: func, + radiusOffset: number.isRequired +}; + +PieRadialLegends.defaultProps = { + radiusOffset: 16 +}; + + +export default PieRadialLegends; diff --git a/src/components/layouts/PieSliceLegends.jsx b/src/components/layouts/PieSliceLegends.jsx new file mode 100644 index 000000000..e11d917bb --- /dev/null +++ b/src/components/layouts/PieSliceLegends.jsx @@ -0,0 +1,91 @@ +import React, { Component, PropTypes } from 'react'; +import invariant from 'invariant'; +import d3 from 'd3'; +import { midAngle, radiansToDegrees } from '../../ArcUtils'; + + +class PieSliceLegends extends Component { + static createLegendsFromReactElement(element) { + const { props } = element; + + return ({ element, keyProp, arc, data }) => { + let legends = element.selectAll('.slice-legend').data(data, d => d.data[keyProp]); + + const labelFn = props.labelFn || (d => d.data[keyProp]); + + legends.enter() + .append('g') + .attr('class', 'slice-legend') + .attr('transform', d => { + const centroid = arc.centroid(d); + + return `translate(${centroid[0]}, ${centroid[1]})`; + }) + .each(function (d) { + d3.select(this) + .append('circle') + .attr('fill', d3.rgb(d.data.color).darker(1).toString()) + .attr('r', props.radius) + ; + + d3.select(this) + .append('text') + .attr('fill', d => d.data.color) + .attr('text-anchor', 'middle') + ; + }) + ; + + legends + .attr('transform', d => { + const centroid = arc.centroid(d); + let transform = `translate(${centroid[0]}, ${centroid[1]})`; + + if (props.orient) { + const angle = midAngle(d); + transform = `${transform} rotate(${radiansToDegrees(angle)}, 0, 0)`; + } + + return transform; + }) + .each(function (d) { + d3.select(this).select('circle') + .attr('fill', d3.rgb(d.data.color).darker(1).toString()) + ; + + d3.select(this).select('text') + .attr('fill', d => d.data.color) + .text(labelFn(d)) + ; + }) + ; + + legends.exit() + .remove() + ; + }; + } + + render() { + invariant( + false, + ' element is for Pie configuration only and should not be rendered' + ); + } +} + +const { number, bool, func } = PropTypes; + +PieSliceLegends.propTypes = { + labelFn: func, + radius: number.isRequired, + orient: bool.isRequired +}; + +PieSliceLegends.defaultProps = { + radius: 12, + orient: true +}; + + +export default PieSliceLegends; diff --git a/src/components/shapes/Area.jsx b/src/components/shapes/Area.jsx new file mode 100644 index 000000000..bb443093d --- /dev/null +++ b/src/components/shapes/Area.jsx @@ -0,0 +1,60 @@ +import React, { Component, PropTypes } from 'react'; +import d3 from 'd3'; + + +class Area extends Component { + shouldComponentUpdate(nextProps) { + const { + data, + xScale, yScale, + width, height, + interpolation, + transitionDuration, + transitionEasing + } = nextProps; + + const element = d3.select(React.findDOMNode(this)); + + const area = d3.svg.area() + .x((d, i) => xScale(i)) + .y0(height) + .y1(d => yScale(d)) + .interpolate(interpolation) + ; + + element.datum(data) + .transition() + .duration(transitionDuration) + .ease(transitionEasing) + .attr('d', area) + ; + + return false; + } + + render() { + return ; + } +} + +Area.displayName = 'Area'; + +Area.propTypes = { + data: PropTypes.array.isRequired, + xScale: PropTypes.func.isRequired, + yScale: PropTypes.func.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + interpolation: PropTypes.string.isRequired, + transitionDuration: PropTypes.number.isRequired, + transitionEasing: PropTypes.string.isRequired +}; + +Area.defaultProps = { + interpolation: 'linear', + transitionDuration: 600, + transitionEasing: 'cubic-out' +}; + + +export default Area; diff --git a/src/components/shapes/Line.jsx b/src/components/shapes/Line.jsx new file mode 100644 index 000000000..4c19eede4 --- /dev/null +++ b/src/components/shapes/Line.jsx @@ -0,0 +1,56 @@ +import React, { Component, PropTypes } from 'react'; +import d3 from 'd3'; + + +class Line extends Component { + shouldComponentUpdate(nextProps) { + const { + data, + xScale, yScale, + interpolation, + transitionDuration, + transitionEasing + } = nextProps; + + const element = d3.select(React.findDOMNode(this)); + + const line = d3.svg.line() + .x((d, i) => xScale(i)) + .y(d => yScale(d)) + .interpolate(interpolation) + ; + + element.datum(data) + .transition() + .duration(transitionDuration) + .ease(transitionEasing) + .attr('d', line) + ; + + return false; + } + + render() { + return ; + } +} + +Line.displayName = 'Line'; + +Line.propTypes = { + data: PropTypes.array.isRequired, + xScale: PropTypes.func.isRequired, + yScale: PropTypes.func.isRequired, + interpolation: PropTypes.string.isRequired, + transitionDuration: PropTypes.number.isRequired, + transitionEasing: PropTypes.string.isRequired +}; + +Line.defaultProps = { + interpolation: 'linear', + transitionDuration: 600, + transitionEasing: 'cubic-out' +}; + + +export default Line; diff --git a/src/index.js b/src/index.js new file mode 100644 index 000000000..d43304de0 --- /dev/null +++ b/src/index.js @@ -0,0 +1,22 @@ +import Chart from './components/Chart.jsx'; +import Pie from './components/layouts/Pie.jsx'; +import PieColumnLegends from './components/layouts/PieColumnLegends.jsx'; +import PieRadialLegends from './components/layouts/PieRadialLegends.jsx'; +import PieSliceLegends from './components/layouts/PieSliceLegends.jsx'; +import AreaShape from './components/shapes/Area.jsx'; +import LineShape from './components/shapes/Line.jsx'; +import AxisX from './components/axes/AxisX.jsx'; +import AxisY from './components/axes/AxisY.jsx'; + + +export default { + Chart, + Pie, + PieColumnLegends, + PieRadialLegends, + PieSliceLegends, + AreaShape, + LineShape, + AxisX, + AxisY +};