kien-react-minimal-pie-chart
Version: 
Lightweight but versatile SVG pie/donut charts for React
473 lines (407 loc) • 14.2 kB
JavaScript
import React, { Component } from 'react';
import PropTypes from 'prop-types';
function _extends() {
  _extends = Object.assign || function (target) {
    for (var i = 1; i < arguments.length; i++) {
      var source = arguments[i];
      for (var key in source) {
        if (Object.prototype.hasOwnProperty.call(source, key)) {
          target[key] = source[key];
        }
      }
    }
    return target;
  };
  return _extends.apply(this, arguments);
}
function _inheritsLoose(subClass, superClass) {
  subClass.prototype = Object.create(superClass.prototype);
  subClass.prototype.constructor = subClass;
  subClass.__proto__ = superClass;
}
function _objectWithoutPropertiesLoose(source, excluded) {
  if (source == null) return {};
  var target = {};
  var sourceKeys = Object.keys(source);
  var key, i;
  for (i = 0; i < sourceKeys.length; i++) {
    key = sourceKeys[i];
    if (excluded.indexOf(key) >= 0) continue;
    target[key] = source[key];
  }
  return target;
}
var partialCircle = function partialCircle(cx, cy, r, start, end) {
  var length = end - start;
  if (length === 0) return [];
  var fromX = r * Math.cos(start) + cx;
  var fromY = r * Math.sin(start) + cy;
  var toX = r * Math.cos(end) + cx;
  var toY = r * Math.sin(end) + cy;
  var large = Math.abs(length) <= Math.PI ? '0' : '1';
  var sweep = length < 0 ? '0' : '1';
  return [['M', fromX, fromY], ['A', r, r, 0, large, sweep, toX, toY]];
};
var svgPartialCircle = partialCircle;
var PI = Math.PI;
function degreesToRadians(degrees) {
  return degrees * PI / 180;
}
function evaluateViewBoxSize(ratio, baseSize) {
  // Wide ratio
  if (ratio > 1) {
    return "0 0 " + baseSize + " " + baseSize / ratio;
  } // Narrow/squared ratio
  return "0 0 " + baseSize * ratio + " " + baseSize;
}
function evaluateLabelTextAnchor(_temp) {
  var _ref = _temp === void 0 ? {} : _temp,
      labelPosition = _ref.labelPosition,
      lineWidth = _ref.lineWidth,
      labelHorizontalShift = _ref.labelHorizontalShift;
  // Label in the vertical center
  if (labelHorizontalShift === 0) {
    return 'middle';
  } // Outward label
  if (labelPosition > 100) {
    return labelHorizontalShift > 0 ? 'start' : 'end';
  } // Inward label
  var innerRadius = 100 - lineWidth;
  if (labelPosition < innerRadius) {
    return labelHorizontalShift > 0 ? 'end' : 'start';
  } // Overlying label
  return 'middle';
}
function valueBetween(value, min, max) {
  if (value > max) return max;
  if (value < min) return min;
  return value;
}
function extractPercentage(value, percentage) {
  return value * percentage / 100;
}
function makePathCommands(cx, cy, startAngle, lengthAngle, radius) {
  var patchedLengthAngle = valueBetween(lengthAngle, -359.999, 359.999);
  return svgPartialCircle(cx, cy, // center X and Y
  radius, degreesToRadians(startAngle), degreesToRadians(startAngle + patchedLengthAngle)).map(function (command) {
    return command.join(' ');
  }).join(' ');
}
function ReactMinimalPieChartPath(_ref) {
  var cx = _ref.cx,
      cy = _ref.cy,
      startAngle = _ref.startAngle,
      lengthAngle = _ref.lengthAngle,
      radius = _ref.radius,
      lineWidth = _ref.lineWidth,
      reveal = _ref.reveal,
      title = _ref.title,
      props = _objectWithoutPropertiesLoose(_ref, ["cx", "cy", "startAngle", "lengthAngle", "radius", "lineWidth", "reveal", "title"]);
  var actualRadio = radius - lineWidth / 2;
  var pathCommands = makePathCommands(cx, cy, startAngle, lengthAngle, actualRadio);
  var strokeDasharray;
  var strokeDashoffset; // Animate/hide paths with "stroke-dasharray" + "stroke-dashoffset"
  // https://css-tricks.com/svg-line-animation-works/
  if (typeof reveal === 'number') {
    var pathLength = degreesToRadians(actualRadio) * lengthAngle;
    strokeDasharray = Math.abs(pathLength);
    strokeDashoffset = strokeDasharray - extractPercentage(strokeDasharray, reveal);
  }
  return React.createElement("path", _extends({
    d: pathCommands,
    strokeWidth: lineWidth,
    strokeDasharray: strokeDasharray,
    strokeDashoffset: strokeDashoffset
  }, props), title && React.createElement("title", null, title));
}
ReactMinimalPieChartPath.displayName = 'ReactMinimalPieChartPath';
ReactMinimalPieChartPath.propTypes = {
  cx: PropTypes.number.isRequired,
  cy: PropTypes.number.isRequired,
  startAngle: PropTypes.number,
  lengthAngle: PropTypes.number,
  radius: PropTypes.number,
  lineWidth: PropTypes.number,
  reveal: PropTypes.number,
  title: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
};
ReactMinimalPieChartPath.defaultProps = {
  startAngle: 0,
  lengthAngle: 0,
  lineWidth: 100,
  radius: 100
};
var stylePropType = PropTypes.objectOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string]));
var dataPropType = PropTypes.arrayOf(PropTypes.shape({
  title: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  value: PropTypes.number.isRequired,
  color: PropTypes.string,
  key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  style: stylePropType
}));
function ReactMinimalPieChartLabel(_ref) {
  var data = _ref.data,
      dataIndex = _ref.dataIndex,
      color = _ref.color,
      props = _objectWithoutPropertiesLoose(_ref, ["data", "dataIndex", "color"]);
  return React.createElement("text", _extends({
    textAnchor: "middle",
    dominantBaseline: "middle",
    fill: color
  }, props));
}
ReactMinimalPieChartLabel.displayName = 'ReactMinimalPieChartLabel';
ReactMinimalPieChartLabel.propTypes = {
  data: dataPropType,
  dataIndex: PropTypes.number,
  color: PropTypes.string
};
var VIEWBOX_SIZE = 100;
var VIEWBOX_HALF_SIZE = VIEWBOX_SIZE / 2;
function sumValues(data) {
  return data.reduce(function (acc, dataEntry) {
    return acc + dataEntry.value;
  }, 0);
} // Append "percentage", "degrees" and "startOffset" into each data entry
function extendData(_ref) {
  var data = _ref.data,
      totalAngle = _ref.lengthAngle,
      totalValue = _ref.totalValue,
      paddingAngle = _ref.paddingAngle;
  var total = totalValue || sumValues(data);
  var normalizedTotalAngle = valueBetween(totalAngle, -360, 360);
  var numberOfPaddings = Math.abs(normalizedTotalAngle) === 360 ? data.length : data.length - 1;
  var singlePaddingDegrees = Math.abs(paddingAngle) * Math.sign(totalAngle);
  var degreesTakenByPadding = singlePaddingDegrees * numberOfPaddings;
  var degreesTakenByPaths = normalizedTotalAngle - degreesTakenByPadding;
  var lastSegmentEnd = 0;
  return data.map(function (dataEntry) {
    var valueInPercentage = dataEntry.value / total * 100;
    var degrees = extractPercentage(degreesTakenByPaths, valueInPercentage);
    var startOffset = lastSegmentEnd;
    lastSegmentEnd = lastSegmentEnd + degrees + singlePaddingDegrees;
    return _extends({
      percentage: valueInPercentage,
      degrees: degrees,
      startOffset: startOffset
    }, dataEntry);
  });
}
function makeSegmentTransitionStyle(duration, easing, furtherStyles) {
  if (furtherStyles === void 0) {
    furtherStyles = {};
  }
  // Merge CSS transition necessary for chart animation with the ones provided by "segmentsStyle"
  var transition = ["stroke-dashoffset " + duration + "ms " + easing, furtherStyles.transition].filter(Boolean).join(',');
  return {
    transition: transition
  };
}
function renderLabelItem(option, props, value) {
  if (React.isValidElement(option)) {
    return React.cloneElement(option, props);
  }
  var label = value;
  if (typeof option === 'function') {
    label = option(props);
    if (React.isValidElement(label)) {
      return label;
    }
  }
  return React.createElement(ReactMinimalPieChartLabel, props, label);
}
function renderLabels(data, props) {
  var labelPosition = extractPercentage(props.radius, props.labelPosition);
  return data.map(function (dataEntry, index) {
    var startAngle = props.startAngle + dataEntry.startOffset;
    var halfAngle = startAngle + dataEntry.degrees / 2;
    var halfAngleRadians = degreesToRadians(halfAngle);
    var dx = Math.cos(halfAngleRadians) * labelPosition;
    var dy = Math.sin(halfAngleRadians) * labelPosition; // This object is passed as props to the "label" component
    var labelProps = {
      key: "label-" + (dataEntry.key || index),
      x: props.labelCenter ? props.cx : 47 + Math.cos(halfAngleRadians) * labelPosition,
      y: props.labelCenter ? props.cy : 47 + Math.sin(halfAngleRadians) * labelPosition,
      dx: dx,
      dy: dy,
      textAnchor: evaluateLabelTextAnchor({
        lineWidth: props.lineWidth,
        labelPosition: props.labelPosition,
        labelHorizontalShift: dx
      }),
      height: props.labelHeight,
      width: props.labelWidth,
      data: data,
      dataIndex: index,
      style: props.labelStyle
    };
    return renderLabelItem(props.label, labelProps, dataEntry.value);
  });
}
function renderSegments(data, props, hide) {
  var style = props.segmentsStyle;
  var reveal;
  if (props.animate) {
    var transitionStyle = makeSegmentTransitionStyle(props.animationDuration, props.animationEasing, style);
    style = Object.assign({}, style, transitionStyle);
  } // Hide/reveal the segment?
  if (hide === true) {
    reveal = 0;
  } else if (typeof props.reveal === 'number') {
    reveal = props.reveal;
  } else if (hide === false) {
    reveal = 100;
  }
  var paths = data.map(function (dataEntry, index) {
    var startAngle = props.startAngle + dataEntry.startOffset;
    return React.createElement(ReactMinimalPieChartPath, {
      key: dataEntry.key || index,
      cx: props.cx,
      cy: props.cy,
      startAngle: startAngle,
      lengthAngle: dataEntry.degrees,
      radius: props.radius,
      lineWidth: extractPercentage(props.radius, props.lineWidth),
      reveal: reveal,
      title: dataEntry.title,
      style: Object.assign({}, style, dataEntry.style),
      stroke: dataEntry.color,
      strokeLinecap: props.rounded ? 'round' : undefined,
      fill: "none",
      onMouseOver: props.onMouseOver && function (e) {
        return props.onMouseOver(e, props.data, index);
      },
      onMouseOut: props.onMouseOut && function (e) {
        return props.onMouseOut(e, props.data, index);
      },
      onClick: props.onClick && function (e) {
        return props.onClick(e, props.data, index);
      }
    });
  });
  if (props.background) {
    paths.unshift(React.createElement(ReactMinimalPieChartPath, {
      key: "bg",
      cx: props.cx,
      cy: props.cy,
      startAngle: props.startAngle,
      lengthAngle: props.lengthAngle,
      radius: props.radius,
      lineWidth: extractPercentage(props.radius, props.lineWidth),
      stroke: props.background,
      strokeLinecap: props.rounded ? 'round' : undefined,
      fill: "none"
    }));
  }
  return paths;
}
var ReactMinimalPieChart =
/*#__PURE__*/
function (_Component) {
  _inheritsLoose(ReactMinimalPieChart, _Component);
  function ReactMinimalPieChart(props) {
    var _this;
    _this = _Component.call(this, props) || this;
    if (_this.props.animate === true) {
      _this.hideSegments = true;
    }
    return _this;
  }
  var _proto = ReactMinimalPieChart.prototype;
  _proto.componentDidMount = function componentDidMount() {
    var _this2 = this;
    if (this.props.animate === true && requestAnimationFrame) {
      this.initialAnimationTimerId = setTimeout(function () {
        _this2.initialAnimationTimerId = null;
        _this2.initialAnimationRAFId = requestAnimationFrame(function () {
          _this2.initialAnimationRAFId = null;
          _this2.startAnimation();
        });
      });
    }
  };
  _proto.componentWillUnmount = function componentWillUnmount() {
    if (this.initialAnimationTimerId) {
      clearTimeout(this.initialAnimationTimerId);
    }
    if (this.initialAnimationRAFId) {
      cancelAnimationFrame(this.initialAnimationRAFId);
    }
  };
  _proto.startAnimation = function startAnimation() {
    this.hideSegments = false;
    this.forceUpdate();
  };
  _proto.render = function render() {
    if (this.props.data === undefined) {
      return null;
    }
    var extendedData = extendData(this.props);
    return React.createElement("div", {
      className: this.props.className,
      style: this.props.style
    }, React.createElement("svg", {
      viewBox: evaluateViewBoxSize(this.props.ratio, VIEWBOX_SIZE),
      width: "100%",
      height: "100%",
      style: {
        display: 'block',
        position: 'relative'
      }
    }, renderSegments(extendedData, this.props, this.hideSegments), this.props.label && renderLabels(extendedData, this.props), this.props.injectSvg && this.props.injectSvg()), this.props.children);
  };
  return ReactMinimalPieChart;
}(Component);
ReactMinimalPieChart.displayName = 'ReactMinimalPieChart';
ReactMinimalPieChart.propTypes = {
  data: dataPropType,
  cx: PropTypes.number,
  cy: PropTypes.number,
  ratio: PropTypes.number,
  totalValue: PropTypes.number,
  className: PropTypes.string,
  style: stylePropType,
  segmentsStyle: stylePropType,
  background: PropTypes.string,
  startAngle: PropTypes.number,
  lengthAngle: PropTypes.number,
  paddingAngle: PropTypes.number,
  lineWidth: PropTypes.number,
  radius: PropTypes.number,
  rounded: PropTypes.bool,
  animate: PropTypes.bool,
  animationDuration: PropTypes.number,
  animationEasing: PropTypes.string,
  reveal: PropTypes.number,
  children: PropTypes.node,
  injectSvg: PropTypes.func,
  label: PropTypes.oneOfType([PropTypes.func, PropTypes.element, PropTypes.bool]),
  labelPosition: PropTypes.number,
  labelStyle: stylePropType,
  onMouseOver: PropTypes.func,
  onMouseOut: PropTypes.func,
  onClick: PropTypes.func,
  labelCenter: PropTypes.bool
};
ReactMinimalPieChart.defaultProps = {
  cx: VIEWBOX_HALF_SIZE,
  cy: VIEWBOX_HALF_SIZE,
  ratio: 1,
  startAngle: 0,
  lengthAngle: 360,
  paddingAngle: 0,
  lineWidth: 100,
  radius: VIEWBOX_HALF_SIZE,
  rounded: false,
  animate: false,
  animationDuration: 500,
  animationEasing: 'ease-out',
  label: false,
  labelPosition: 50,
  onMouseOver: undefined,
  onMouseOut: undefined,
  onClick: undefined,
  labelCenter: true
};
export default ReactMinimalPieChart;
//# sourceMappingURL=index.js.map