-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comparative Formula Widget #504
Changes from 8 commits
ccd1eda
2ef27fe
f76ccb7
faca867
c711fc3
831f9cf
67e294d
6a84ad4
d1fe483
e9ad733
13cbd86
c3d338d
9036400
6e05111
2165a5a
22c4f90
b6dda28
a175d32
dd049f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import useAnimatedNumber from '../hooks/useAnimatedNumber'; | ||
|
||
/** | ||
* Renders a <AnimatedNumber /> widget | ||
* @param {Object} props | ||
* @param {boolean} props.enabled | ||
* @param {number} props.value | ||
* @param {{ duration?: number; animateOnMount?: boolean; }} [props.options] | ||
* @param {(n: number) => React.ReactNode} [props.formatter] | ||
*/ | ||
function AnimatedNumber({ enabled, value, options, formatter }) { | ||
const defaultOptions = { | ||
animateOnMount: true, | ||
disabled: enabled === false || value === null || value === undefined | ||
}; | ||
const animated = useAnimatedNumber(value, { ...defaultOptions, ...options }); | ||
return <span>{formatter ? formatter(animated) : animated}</span>; | ||
} | ||
|
||
AnimatedNumber.displayName = 'AnimatedNumber'; | ||
AnimatedNumber.defaultProps = { | ||
enabled: true, | ||
value: 0, | ||
options: {}, | ||
formatter: null | ||
}; | ||
|
||
export const animationOptionsPropTypes = PropTypes.shape({ | ||
duration: PropTypes.number, | ||
animateOnMount: PropTypes.bool | ||
}); | ||
|
||
AnimatedNumber.propTypes = { | ||
enabled: PropTypes.bool, | ||
value: PropTypes.number.isRequired, | ||
options: animationOptionsPropTypes, | ||
formatter: PropTypes.func | ||
}; | ||
|
||
export default AnimatedNumber; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import { useEffect, useRef, useState } from "react"; | ||
import { animateValue } from '../widgets/utils/animations'; | ||
|
||
/** | ||
* React hook to handle animating value changes over time, abstracting the necesary state, refs and effects | ||
* @param {number} value | ||
* @param {{ disabled?: boolean; duration?: number; animateOnMount?: boolean }} [options] | ||
*/ | ||
export default function useAnimatedNumber(value, options = {}) { | ||
const { disabled, duration, animateOnMount } = options; | ||
|
||
// starting with a -1 to supress a typescript warning | ||
const requestAnimationFrameRef = useRef(-1); | ||
|
||
// if we want to run the animation on mount, we set the start value as 0 and animate to the start value | ||
const [animatedValue, setAnimatedValue] = useState(() => animateOnMount ? 0 : value); | ||
|
||
useEffect(() => { | ||
if (!disabled) { | ||
animateValue({ | ||
start: animatedValue || 0, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to be used also here... initialValue There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here also? Wouldn't I was setting this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then I'm ok |
||
end: value, | ||
duration: duration || 500, // 500ms | ||
drawFrame: (val) => setAnimatedValue(val), | ||
requestRef: requestAnimationFrameRef | ||
}); | ||
} else { | ||
setAnimatedValue(value) | ||
} | ||
|
||
return () => { | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
cancelAnimationFrame(requestAnimationFrameRef.current); | ||
} | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [value, disabled, duration]); | ||
|
||
return animatedValue; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -188,3 +188,36 @@ export type LegendRamp = { | |
colors?: string | string[] | number[][]; | ||
}; | ||
}; | ||
|
||
export type AnimationOptions = { | ||
duration?: number; | ||
animateOnMount?: boolean; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to add here the |
||
}; | ||
|
||
export type AnimatedNumber = { | ||
enabled: boolean; | ||
value: number; | ||
options?: AnimationOptions; | ||
formatter: (n: number) => React.ReactNode; | ||
}; | ||
|
||
export type FormulaLabels = { | ||
prefix?: React.ReactNode; | ||
suffix?: React.ReactNode; | ||
note?: React.ReactNode; | ||
}; | ||
|
||
export type FormulaColors = { | ||
[key in keyof FormulaLabels]?: string; | ||
} & { | ||
value?: string; | ||
}; | ||
|
||
export type ComparativeFormulaWidgetUI = { | ||
data: number[]; | ||
labels?: FormulaLabels[]; | ||
colors?: FormulaColors[]; | ||
animated?: boolean; | ||
animationOptions?: AnimationOptions; | ||
formatter?: (n: number) => React.ReactNode; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import React from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { Box, makeStyles, Typography } from '@material-ui/core'; | ||
import { useTheme } from '@material-ui/core'; | ||
import AnimatedNumber, { | ||
animationOptionsPropTypes | ||
} from '../custom-components/AnimatedNumber'; | ||
|
||
const IDENTITY_FN = (v) => v; | ||
const EMPTY_ARRAY = []; | ||
|
||
const useStyles = makeStyles((theme) => ({ | ||
formulaChart: {}, | ||
formulaGroup: { | ||
'& + $formulaGroup': { | ||
marginTop: theme.spacing(2) | ||
} | ||
}, | ||
firstLine: { | ||
margin: 0, | ||
...theme.typography.h5, | ||
fontWeight: Number(theme.typography.fontWeightMedium), | ||
color: theme.palette.text.primary, | ||
display: 'flex' | ||
}, | ||
unit: { | ||
marginLeft: theme.spacing(0.5) | ||
}, | ||
unitBefore: { | ||
marginLeft: 0, | ||
marginRight: theme.spacing(0.5) | ||
}, | ||
note: { | ||
display: 'inline-block', | ||
marginTop: theme.spacing(0.5) | ||
} | ||
})); | ||
|
||
/** | ||
* Renders a <ComparativeFormulaWidgetUI /> widget | ||
* @param {Object} props | ||
* @param {number[]} props.data | ||
* @param {{ prefix?: string; suffix?: string; note?: string }[]} [props.labels] | ||
* @param {{ prefix?: string; suffix?: string; note?: string; value?: string }[]} [props.colors] | ||
* @param {boolean} [props.animated] | ||
* @param {{ duration?: number; animateOnMount?: boolean; }} [props.animationOptions] | ||
* @param {(v: number) => React.ReactNode} [props.formatter] | ||
*/ | ||
function ComparativeFormulaWidgetUI({ | ||
data = EMPTY_ARRAY, | ||
labels = EMPTY_ARRAY, | ||
colors = EMPTY_ARRAY, | ||
animated = true, | ||
animationOptions, | ||
formatter = IDENTITY_FN | ||
}) { | ||
const theme = useTheme(); | ||
const classes = useStyles(); | ||
|
||
function getColor(index) { | ||
return colors[index] || {}; | ||
} | ||
function getLabel(index) { | ||
return labels[index] || {}; | ||
} | ||
|
||
return ( | ||
<div className={classes.formulaChart}> | ||
{data | ||
.filter((n) => n !== undefined) | ||
.map((d, i) => ( | ||
<div className={classes.formulaGroup} key={i}> | ||
<div className={classes.firstLine}> | ||
{getLabel(i).prefix ? ( | ||
<Box color={getColor(i).prefix || theme.palette.text.secondary}> | ||
<Typography | ||
color='inherit' | ||
component='span' | ||
variant='subtitle2' | ||
className={[classes.unit, classes.unitBefore].join(' ')} | ||
> | ||
{getLabel(i).prefix} | ||
</Typography> | ||
</Box> | ||
) : null} | ||
<Box color={getColor(i).value}> | ||
<AnimatedNumber | ||
value={d || 0} | ||
enabled={animated} | ||
options={animationOptions} | ||
formatter={formatter} | ||
/> | ||
</Box> | ||
{getLabel(i).suffix ? ( | ||
<Box color={getColor(i).suffix || theme.palette.text.secondary}> | ||
<Typography | ||
color='inherit' | ||
component='span' | ||
variant='subtitle2' | ||
className={classes.unit} | ||
> | ||
{getLabel(i).suffix} | ||
</Typography> | ||
</Box> | ||
) : null} | ||
</div> | ||
{getLabel(i).note ? ( | ||
<Box color={getColor(i).note}> | ||
<Typography className={classes.note} color='inherit' variant='caption'> | ||
{getLabel(i).note} | ||
</Typography> | ||
</Box> | ||
) : null} | ||
</div> | ||
))} | ||
</div> | ||
); | ||
} | ||
|
||
ComparativeFormulaWidgetUI.displayName = 'ComparativeFormulaWidgetUI'; | ||
ComparativeFormulaWidgetUI.defaultProps = { | ||
data: EMPTY_ARRAY, | ||
labels: EMPTY_ARRAY, | ||
colors: EMPTY_ARRAY, | ||
animated: true, | ||
animationOptions: {}, | ||
formatter: IDENTITY_FN | ||
}; | ||
|
||
const formulaLabelsPropTypes = PropTypes.shape({ | ||
prefix: PropTypes.string, | ||
suffix: PropTypes.string, | ||
note: PropTypes.string | ||
}); | ||
|
||
const formulaColorsPropTypes = PropTypes.shape({ | ||
prefix: PropTypes.string, | ||
suffix: PropTypes.string, | ||
note: PropTypes.string, | ||
value: PropTypes.string | ||
}); | ||
|
||
ComparativeFormulaWidgetUI.propTypes = { | ||
data: PropTypes.arrayOf(PropTypes.number).isRequired, | ||
labels: PropTypes.arrayOf(formulaLabelsPropTypes), | ||
colors: PropTypes.arrayOf(formulaColorsPropTypes), | ||
animated: PropTypes.bool, | ||
animationOptions: animationOptionsPropTypes, | ||
formatter: PropTypes.func | ||
}; | ||
|
||
export default ComparativeFormulaWidgetUI; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import React from 'react'; | ||
import ComparativeFormulaWidgetUI from '../../../src/widgets/ComparativeFormulaWidgetUI'; | ||
|
||
const options = { | ||
title: 'Custom Components/ComparativeFormulaWidgetUI', | ||
component: ComparativeFormulaWidgetUI | ||
}; | ||
|
||
export default options; | ||
|
||
const Template = (args) => <ComparativeFormulaWidgetUI {...args} />; | ||
|
||
export const Default = Template.bind({}); | ||
Default.args = { | ||
data: [1245, 3435.9], | ||
labels: [ | ||
{ prefix: '$', suffix: ' sales', note: 'label 1' }, | ||
{ prefix: '$', suffix: ' sales', note: 'label 2' } | ||
], | ||
colors: [{ note: '#ff9900' }, { note: '#6732a8' }] | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea of a hook to encapsulate animation. I think it's a good addition!
Just one comment, we're assuming that values are positive, and we should always animate from 0. But what about adding an extra param to indicate
initialValue?: number
? (with 0 as default)?