react-gear-chart
Version:

270 lines (250 loc) • 7.92 kB
JavaScript
// @flow
import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { TransitionMotion, spring } from 'react-motion'
import classnames from 'classnames'
import Tooth from './Tooth'
import { AnnulusViewport, NormalizeAngleRange } from '../utils/math'
import { shouldUpdate } from 'should-update'
type Strip = {
color: string,
weight: number,
}
type ToothItem = {
mode: string,
label: string,
strips: Strip | Array<Strip>,
}
type GearListProps = {
id: string,
startAngle: number,
endAngle: number,
innerRadius: number,
outerRadius: number,
margin: number,
limit: number,
clockwise: boolean, // items line-up direction
animate: boolean,
clockwiseAnimate: boolean,
motionConfig: object,
items: Array<ToothItem>,
extra: React$Element,
}
const shoudlUpdateStates = ['childFocused']
const shouldUpdateProps = [
'id',
'startAngle',
'endAngle',
'innerRadius',
'outerRadius',
'margin',
'limit',
'clockwise',
'items',
'extra',
]
const Styles = {
container: {
display: 'inline-block',
padding: '1em',
},
pointer: {
cursor: 'pointer',
},
}
/**
* Use Polar Coordinate System with convention:
* https://en.wikipedia.org/wiki/Polar_coordinate_system#/media/File:Polar_graph_paper.svg
*/
export default class GearListChart extends PureComponent<void, GearListProps, void> {
static getToothParam(index, angle, margin, baseAngle, clockwise = false) {
let _factor = clockwise ? -1 : 1
let start = baseAngle + index * angle * _factor
let end = start + angle * _factor - margin * _factor
return [start, end].sort((a, b) => a - b)
}
static getRegistrationName(evt) {
return evt.dispatchConfig.registrationName || evt.dispatchConfig.phasedRegistrationNames.bubbled
}
state = {
childFocused: false,
}
mouseEventProxy = evt => {
let name = GearListChart.getRegistrationName(evt)
this.props[name](evt)
if (name === 'onClick') {
let self = this.chart
let teeth = self.querySelectorAll('.tooth')
let focusedTooth = self.querySelector('.tooth.focused')
if (focusedTooth && focusedTooth.contains(evt.target) && this.state.childFocused) {
this.setState({ childFocused: false })
} else {
this.setState({ childFocused: true })
}
teeth.forEach(t => t.classList.remove('focused'))
}
}
/** willEnter for react-motion */
motionWillEnter = () => {
let { clockwiseAnimate } = this.props
let totalAnagle = this.totalAnagle()
let style = {
offsetAngle: clockwiseAnimate ? -totalAnagle : totalAnagle,
opacity: 0,
}
return style
}
/** willLeave for react-motion */
motionWillLeave = () => {
let { clockwiseAnimate, motionConfig } = this.props
let totalAnagle = this.totalAnagle()
let style = {
offsetAngle: spring(clockwiseAnimate ? totalAnagle : -totalAnagle, motionConfig),
opacity: spring(0, motionConfig),
}
return style
}
/** Clear focus status if need to */
clearFocus = () => {
let focused = this.chart.querySelector('.tooth.focused')
focused && focused.classList.remove('focused')
this.setState({ childFocused: false })
}
isFocused = () => this.chart.classList.contains('child-focused')
totalAnagle() {
let { endAngle, startAngle } = this.props
let [_startAngle, _endAngle] = NormalizeAngleRange(startAngle, endAngle)
return _endAngle - _startAngle
}
shouldComponentUpdate(nextProps, nextState) {
return (
shouldUpdate(shoudlUpdateStates, this.state, nextState) || shouldUpdate(shouldUpdateProps, this.props, nextProps)
)
}
render() {
let {
id,
innerRadius,
outerRadius,
items,
margin,
limit,
startAngle,
endAngle,
clockwise,
animate,
clockwiseAnimate,
motionConfig,
className,
style,
extra,
onMouseMove,
onMouseEnter,
onMouseLeave,
onMouseOver,
onClick,
...restProps
} = this.props
if (!items || !items.length) return null
let { childFocused } = this.state
let [_startAngle, _endAngle] = NormalizeAngleRange(startAngle, endAngle)
let [width, height, cx, cy] = AnnulusViewport(startAngle, endAngle, outerRadius, innerRadius, 10)
let _perItemAngle = this.totalAnagle() / items.length
if (_perItemAngle > limit) _perItemAngle = limit
if (clockwise) {
/* shift half of the margin to centerize teeth */
_startAngle = _endAngle - margin / 2
} else {
_startAngle = _startAngle + margin / 2
}
return (
<div
id={id}
ref={r => (this.chart = r)}
className={classnames('gear-list-chart', className, childFocused ? 'child-focused' : '')}
style={{ ...Styles.container, ...style }}
{...restProps}
>
<svg width={width} height={height}>
<TransitionMotion
willEnter={this.motionWillEnter}
willLeave={animate ? this.motionWillLeave : undefined}
defaultStyles={items.map((item, i) => ({
key: item.id || String(i),
data: item,
style: {
offsetAngle: this.totalAnagle() * (clockwiseAnimate ? -1 : 1),
opacity: 1,
},
}))}
styles={items.map((item, i) => ({
key: item.id || String(i),
data: item,
style: {
offsetAngle: animate ? spring(0, motionConfig) : 0,
opacity: 1,
},
}))}
>
{interpolated => (
<g transform={`translate(${cx}, ${cy})`}>
{interpolated.map((conf, i) => {
let item = conf.data
// before item's leave it stays in interpolated array, have to get correct position
let leaveItemsCount = interpolated.length - items.length
if (i >= leaveItemsCount) {
i -= leaveItemsCount
}
let [start, end] = GearListChart.getToothParam(i, _perItemAngle, margin, _startAngle, clockwise)
return (
<g key={conf.key || i}>
<Tooth
style={{ cursor: onClick ? 'pointer' : 'inherit', opacity: conf.style.opacity }}
startAngle={start}
endAngle={end}
offsetAngle={+conf.style.offsetAngle}
cx={0}
cy={0}
outerRadius={outerRadius}
innerRadius={innerRadius}
index={i}
data={item}
mode={item.mode}
label={item.label}
strips={item.strips}
onMouseMove={onMouseMove && this.mouseEventProxy}
onMouseLeave={onMouseLeave && this.mouseEventProxy}
onMouseEnter={onMouseEnter && this.mouseEventProxy}
onMouseOver={onMouseOver && this.mouseEventProxy}
onClick={onClick && this.mouseEventProxy}
extra={extra}
/>
</g>
)
})}
</g>
)}
</TransitionMotion>
</svg>
</div>
)
}
}
GearListChart.defaultProps = {
limit: 90,
startAngle: 0,
endAngle: 0,
margin: 0,
clockwise: true,
clockwiseAnimate: true,
animate: true,
motionConfig: {},
}
GearListChart.propTypes = {
startAngle: PropTypes.number.isRequired,
endAngle: PropTypes.number.isRequired,
innerRadius: PropTypes.number.isRequired,
outerRadius: PropTypes.number.isRequired,
margin: PropTypes.number,
limit: PropTypes.number,
}