From ccd1eda1668aab71ad7ab07ac0bca0a1e066443c Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 14:04:47 +0200 Subject: [PATCH 01/15] feat(ui): implement ComparativeFormulaWidgetUI --- packages/react-ui/package.json | 3 +- .../src/custom-components/AnimatedNumber.js | 43 ++++++ packages/react-ui/src/index.d.ts | 2 + packages/react-ui/src/index.js | 2 + packages/react-ui/src/types.d.ts | 36 +++++ .../src/widgets/ComparativeFormulaWidgetUI.js | 142 ++++++++++++++++++ yarn.lock | 5 + 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 packages/react-ui/src/custom-components/AnimatedNumber.js create mode 100644 packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index c021e6a5f..76863ff0f 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -72,7 +72,8 @@ "webpack-cli": "^4.5.0" }, "dependencies": { - "@babel/runtime": "^7.13.9" + "@babel/runtime": "^7.13.9", + "use-animate-number": "^1.0.5" }, "peerDependencies": { "@carto/react-core": "^1.5.0-alpha.4", diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js new file mode 100644 index 000000000..61f5e0167 --- /dev/null +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import useAnimateNumber from 'use-animate-number'; + +function countDecimals(n) { + if (Math.floor(n) === n) return 0; + return String(n).split('.')[1]?.length || 0; +} + +function AnimatedNumber({ enabled, value, options, formatter }) { + const defaultOptions = { + direct: true, + decimals: countDecimals(value), + disabled: enabled === false || value === null || value === undefined + }; + const [animated] = useAnimateNumber(value, { ...defaultOptions, ...options }); + return {formatter ? formatter(animated) : animated}; +} + +AnimatedNumber.displayName = 'AnimatedNumber'; +AnimatedNumber.defaultProps = { + enabled: true, + value: 0, + options: {}, + formatter: null +}; + +export const animationOptionsPropTypes = PropTypes.shape({ + duration: PropTypes.number, + enterance: PropTypes.bool, + direct: PropTypes.bool, + disabled: PropTypes.bool, + decimals: PropTypes.number +}); + +AnimatedNumber.propTypes = { + enabled: PropTypes.bool, + value: PropTypes.number.isRequired, + options: animationOptionsPropTypes, + formatter: PropTypes.func +}; + +export default AnimatedNumber; diff --git a/packages/react-ui/src/index.d.ts b/packages/react-ui/src/index.d.ts index 10060bdd7..eea9cbb02 100644 --- a/packages/react-ui/src/index.d.ts +++ b/packages/react-ui/src/index.d.ts @@ -22,6 +22,7 @@ import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; +import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; export { cartoThemeOptions, @@ -42,6 +43,7 @@ export { TableWidgetUI, LegendWidgetUI, RangeWidgetUI, + ComparativeFormulaWidgetUI, LEGEND_TYPES, NoDataAlert, LegendCategories, diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index 23679104d..78bce6907 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -14,6 +14,7 @@ import ScatterPlotWidgetUI from './widgets/ScatterPlotWidgetUI'; import TimeSeriesWidgetUI from './widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; import RangeWidgetUI from './widgets/RangeWidgetUI'; +import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; @@ -42,6 +43,7 @@ export { ScatterPlotWidgetUI, TimeSeriesWidgetUI, FeatureSelectionWidgetUI, + ComparativeFormulaWidgetUI, CHART_TYPES as TIME_SERIES_CHART_TYPES, TableWidgetUI, LegendWidgetUI, diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 89fd53651..a42095aea 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -188,3 +188,39 @@ export type LegendRamp = { colors?: string | string[] | number[][]; }; }; + +export type AnimationOptions = Partial<{ + duration: number; + enterance: boolean; + direct: boolean; + disabled: boolean; + decimals: number; +}>; + +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; +}; diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js new file mode 100644 index 000000000..e87e3ba52 --- /dev/null +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -0,0 +1,142 @@ +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) + } +})); + +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 ( +
+ {data + .filter((n) => n !== undefined) + .map((d, i) => ( +
+
+ {getLabel(i).prefix ? ( + + + {getLabel(i).prefix} + + + ) : null} + + + + {getLabel(i).suffix ? ( + + + {getLabel(i).suffix} + + + ) : null} +
+ {getLabel(i).note ? ( + + + {getLabel(i).note} + + + ) : null} +
+ ))} +
+ ); +} + +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), + labels: PropTypes.arrayOf(formulaLabelsPropTypes), + colors: PropTypes.arrayOf(formulaColorsPropTypes), + animated: PropTypes.bool, + animationOptions: animationOptionsPropTypes, + formatter: PropTypes.func +}; + +export default ComparativeFormulaWidgetUI; diff --git a/yarn.lock b/yarn.lock index d78b5ca7a..a0be4be35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17667,6 +17667,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-animate-number@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/use-animate-number/-/use-animate-number-1.0.5.tgz#c224fd3ce81d0b563a5215d714aaa98ad8c67470" + integrity sha512-zAavKn83Z6V6wlatpEp+fmbAS6vMA3tISudw04eSeyXc0AZG98JDDK1U+gmMxV/oATcub+ijgrENMPqHue+SiA== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 2ef27fe3d21bfe589d3b668adbc9d3f45a41c2d1 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 14:17:19 +0200 Subject: [PATCH 02/15] add storybook for ComparativeFormulaWidgetUI --- .../ComparativeFormulaWidgetUI.stories.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js diff --git a/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js new file mode 100644 index 000000000..e4bd611be --- /dev/null +++ b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js @@ -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) => ; + +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' }] +}; From f76ccb7e12794b20effbae09714101c0c494dd2f Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 14:39:38 +0200 Subject: [PATCH 03/15] add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a209809..0a471910b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Not released +- Implement ComparativeFormulaWidgetUI [#504](https://github.com/CartoDB/carto-react/pull/504) + ## 1.5 ### 1.5.0-alpha.4 (2022-10-14) From faca867ec8333c39fa604eaec713c5b694068aa6 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:30:13 +0200 Subject: [PATCH 04/15] add JSDoc for ComparativeFormulaWidgetUI --- .../src/widgets/ComparativeFormulaWidgetUI.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index e87e3ba52..b3440f31d 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -36,6 +36,16 @@ const useStyles = makeStyles((theme) => ({ } })); +/** + * Renders a 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 {Object} [props.animationOptions] + * @param {(v: number) => React.ReactNode} [props.formatter] + */ function ComparativeFormulaWidgetUI({ data = EMPTY_ARRAY, labels = EMPTY_ARRAY, @@ -131,7 +141,7 @@ const formulaColorsPropTypes = PropTypes.shape({ }); ComparativeFormulaWidgetUI.propTypes = { - data: PropTypes.arrayOf(PropTypes.number), + data: PropTypes.arrayOf(PropTypes.number).isRequired, labels: PropTypes.arrayOf(formulaLabelsPropTypes), colors: PropTypes.arrayOf(formulaColorsPropTypes), animated: PropTypes.bool, From c711fc388211888fba79f6eedf2f03d1522e463a Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:31:14 +0200 Subject: [PATCH 05/15] add JSDoc for AnimatedNumber --- packages/react-ui/src/custom-components/AnimatedNumber.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js index 61f5e0167..fd21022b4 100644 --- a/packages/react-ui/src/custom-components/AnimatedNumber.js +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -7,6 +7,14 @@ function countDecimals(n) { return String(n).split('.')[1]?.length || 0; } +/** + * Renders a widget + * @param {Object} props + * @param {boolean} props.enabled + * @param {number} props.value + * @param {{ duration?: number; enterance?: boolean; direct?: boolean; disabled?: boolean; decimals?: number; }} [props.options] + * @param {(n: number) => React.ReactNode} [props.formatter] + */ function AnimatedNumber({ enabled, value, options, formatter }) { const defaultOptions = { direct: true, From 831f9cfe520d86b86325fed402d4632f4b7332b2 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:31:35 +0200 Subject: [PATCH 06/15] export type not exported --- packages/react-ui/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index a42095aea..994155168 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -197,7 +197,7 @@ export type AnimationOptions = Partial<{ decimals: number; }>; -type AnimatedNumber = { +export type AnimatedNumber = { enabled: boolean; value: number; options?: AnimationOptions; From 67e294ddca5483943b11f01169875236f0c525fa Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 14:01:13 +0200 Subject: [PATCH 07/15] implement custom animation hook to replace npm lib --- packages/react-ui/package.json | 3 +- .../src/custom-components/AnimatedNumber.js | 19 +++------ .../react-ui/src/hooks/useAnimatedNumber.js | 39 +++++++++++++++++++ .../src/widgets/ComparativeFormulaWidgetUI.js | 2 +- yarn.lock | 5 --- 5 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 packages/react-ui/src/hooks/useAnimatedNumber.js diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 76863ff0f..c021e6a5f 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -72,8 +72,7 @@ "webpack-cli": "^4.5.0" }, "dependencies": { - "@babel/runtime": "^7.13.9", - "use-animate-number": "^1.0.5" + "@babel/runtime": "^7.13.9" }, "peerDependencies": { "@carto/react-core": "^1.5.0-alpha.4", diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js index fd21022b4..24c794023 100644 --- a/packages/react-ui/src/custom-components/AnimatedNumber.js +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -1,27 +1,21 @@ import React from 'react'; import PropTypes from 'prop-types'; -import useAnimateNumber from 'use-animate-number'; - -function countDecimals(n) { - if (Math.floor(n) === n) return 0; - return String(n).split('.')[1]?.length || 0; -} +import useAnimatedNumber from '../hooks/useAnimatedNumber'; /** * Renders a widget * @param {Object} props * @param {boolean} props.enabled * @param {number} props.value - * @param {{ duration?: number; enterance?: boolean; direct?: boolean; disabled?: boolean; decimals?: number; }} [props.options] + * @param {{ duration?: number; animateOnMount?: boolean; }} [props.options] * @param {(n: number) => React.ReactNode} [props.formatter] */ function AnimatedNumber({ enabled, value, options, formatter }) { const defaultOptions = { - direct: true, - decimals: countDecimals(value), + animateOnMount: true, disabled: enabled === false || value === null || value === undefined }; - const [animated] = useAnimateNumber(value, { ...defaultOptions, ...options }); + const animated = useAnimatedNumber(value, { ...defaultOptions, ...options }); return {formatter ? formatter(animated) : animated}; } @@ -35,10 +29,7 @@ AnimatedNumber.defaultProps = { export const animationOptionsPropTypes = PropTypes.shape({ duration: PropTypes.number, - enterance: PropTypes.bool, - direct: PropTypes.bool, - disabled: PropTypes.bool, - decimals: PropTypes.number + animateOnMount: PropTypes.bool }); AnimatedNumber.propTypes = { diff --git a/packages/react-ui/src/hooks/useAnimatedNumber.js b/packages/react-ui/src/hooks/useAnimatedNumber.js new file mode 100644 index 000000000..c6c77bf83 --- /dev/null +++ b/packages/react-ui/src/hooks/useAnimatedNumber.js @@ -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, + 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; +}; diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index b3440f31d..50ca4045c 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -43,7 +43,7 @@ const useStyles = makeStyles((theme) => ({ * @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 {Object} [props.animationOptions] + * @param {{ duration?: number; animateOnMount?: boolean; }} [props.animationOptions] * @param {(v: number) => React.ReactNode} [props.formatter] */ function ComparativeFormulaWidgetUI({ diff --git a/yarn.lock b/yarn.lock index a0be4be35..d78b5ca7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17667,11 +17667,6 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" -use-animate-number@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/use-animate-number/-/use-animate-number-1.0.5.tgz#c224fd3ce81d0b563a5215d714aaa98ad8c67470" - integrity sha512-zAavKn83Z6V6wlatpEp+fmbAS6vMA3tISudw04eSeyXc0AZG98JDDK1U+gmMxV/oATcub+ijgrENMPqHue+SiA== - use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 6a84ad4bc59f0b0d8cff82e39d79775e4d3b9c62 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 14:11:48 +0200 Subject: [PATCH 08/15] fix animation options exported type --- packages/react-ui/src/types.d.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 994155168..0bcc626d1 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -189,13 +189,10 @@ export type LegendRamp = { }; }; -export type AnimationOptions = Partial<{ - duration: number; - enterance: boolean; - direct: boolean; - disabled: boolean; - decimals: number; -}>; +export type AnimationOptions = { + duration?: number; + animateOnMount?: boolean; +}; export type AnimatedNumber = { enabled: boolean; From d1fe4835ca5015f706e7ba1d6b94c93690955686 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 14:23:25 +0200 Subject: [PATCH 09/15] add AnimatedNumber component - encapsulate animation effects in widgets - wrap around `animateValue` in utils - provide JSDoc documentation - provide TS typings --- .../src/custom-components/AnimatedNumber.js | 42 +++++++++++++++++++ .../react-ui/src/hooks/useAnimatedNumber.js | 39 +++++++++++++++++ packages/react-ui/src/types.d.ts | 12 ++++++ 3 files changed, 93 insertions(+) create mode 100644 packages/react-ui/src/custom-components/AnimatedNumber.js create mode 100644 packages/react-ui/src/hooks/useAnimatedNumber.js diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js new file mode 100644 index 000000000..24c794023 --- /dev/null +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import useAnimatedNumber from '../hooks/useAnimatedNumber'; + +/** + * Renders a 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 {formatter ? formatter(animated) : animated}; +} + +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; diff --git a/packages/react-ui/src/hooks/useAnimatedNumber.js b/packages/react-ui/src/hooks/useAnimatedNumber.js new file mode 100644 index 000000000..c6c77bf83 --- /dev/null +++ b/packages/react-ui/src/hooks/useAnimatedNumber.js @@ -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, + 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; +}; diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 89fd53651..52274ada1 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -188,3 +188,15 @@ export type LegendRamp = { colors?: string | string[] | number[][]; }; }; + +export type AnimationOptions = { + duration?: number; + animateOnMount?: boolean; +}; + +export type AnimatedNumber = { + enabled: boolean; + value: number; + options?: AnimationOptions; + formatter: (n: number) => React.ReactNode; +}; From e9ad733e4114bd490e17e4d98e67f73b2cb0d96e Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 18:05:23 +0200 Subject: [PATCH 10/15] add initialValue optional prop to `useAnimatedNumber` --- .../react-ui/src/custom-components/AnimatedNumber.js | 5 +++-- packages/react-ui/src/hooks/useAnimatedNumber.js | 10 +++++----- packages/react-ui/src/types.d.ts | 1 + 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js index 24c794023..5757ed868 100644 --- a/packages/react-ui/src/custom-components/AnimatedNumber.js +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -7,7 +7,7 @@ import useAnimatedNumber from '../hooks/useAnimatedNumber'; * @param {Object} props * @param {boolean} props.enabled * @param {number} props.value - * @param {{ duration?: number; animateOnMount?: boolean; }} [props.options] + * @param {{ duration?: number; animateOnMount?: boolean; initialValue?: number }} [props.options] * @param {(n: number) => React.ReactNode} [props.formatter] */ function AnimatedNumber({ enabled, value, options, formatter }) { @@ -29,7 +29,8 @@ AnimatedNumber.defaultProps = { export const animationOptionsPropTypes = PropTypes.shape({ duration: PropTypes.number, - animateOnMount: PropTypes.bool + animateOnMount: PropTypes.bool, + initialValue: PropTypes.number }); AnimatedNumber.propTypes = { diff --git a/packages/react-ui/src/hooks/useAnimatedNumber.js b/packages/react-ui/src/hooks/useAnimatedNumber.js index c6c77bf83..ce47039bf 100644 --- a/packages/react-ui/src/hooks/useAnimatedNumber.js +++ b/packages/react-ui/src/hooks/useAnimatedNumber.js @@ -4,21 +4,21 @@ 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] + * @param {{ disabled?: boolean; duration?: number; animateOnMount?: boolean; initialValue?: number; }} [options] */ export default function useAnimatedNumber(value, options = {}) { - const { disabled, duration, animateOnMount } = options; + const { disabled, duration, animateOnMount, initialValue = 0 } = 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); + // if we want to run the animation on mount, we set the starting value of the animated number as 0 (or the number in `initialValue`) and animate to the target value from there + const [animatedValue, setAnimatedValue] = useState(() => animateOnMount ? initialValue : value); useEffect(() => { if (!disabled) { animateValue({ - start: animatedValue || 0, + start: animatedValue, end: value, duration: duration || 500, // 500ms drawFrame: (val) => setAnimatedValue(val), diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 52274ada1..beea4afdf 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -192,6 +192,7 @@ export type LegendRamp = { export type AnimationOptions = { duration?: number; animateOnMount?: boolean; + initialValue?: number }; export type AnimatedNumber = { From 13cbd866dfc83539235e9caa0d9fe231ce69b1ad Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 18:05:54 +0200 Subject: [PATCH 11/15] found a better way to supress ts warning --- packages/react-ui/src/hooks/useAnimatedNumber.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-ui/src/hooks/useAnimatedNumber.js b/packages/react-ui/src/hooks/useAnimatedNumber.js index ce47039bf..47b2c357b 100644 --- a/packages/react-ui/src/hooks/useAnimatedNumber.js +++ b/packages/react-ui/src/hooks/useAnimatedNumber.js @@ -9,8 +9,8 @@ import { animateValue } from '../widgets/utils/animations'; export default function useAnimatedNumber(value, options = {}) { const { disabled, duration, animateOnMount, initialValue = 0 } = options; - // starting with a -1 to supress a typescript warning - const requestAnimationFrameRef = useRef(-1); + /** @type {any} */ + const requestAnimationFrameRef = useRef(); // if we want to run the animation on mount, we set the starting value of the animated number as 0 (or the number in `initialValue`) and animate to the target value from there const [animatedValue, setAnimatedValue] = useState(() => animateOnMount ? initialValue : value); From 903640086f285d909c66c42d81f6ba8ec8c98999 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 18:10:17 +0200 Subject: [PATCH 12/15] adapt jsdoc to useAnimatedValue changes --- packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index 50ca4045c..2dccf6810 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -43,7 +43,7 @@ const useStyles = makeStyles((theme) => ({ * @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 {{ duration?: number; animateOnMount?: boolean; initialValue?: number; }} [props.animationOptions] * @param {(v: number) => React.ReactNode} [props.formatter] */ function ComparativeFormulaWidgetUI({ From 6e051112ac8954a9b305f562a0be498dcea8521c Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 18:12:27 +0200 Subject: [PATCH 13/15] add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a209809..836b45c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Not released +- AnimatedNumber component with hook wrapping `animateValue` [#509](https://github.com/CartoDB/carto-react/pull/509) + ## 1.5 ### 1.5.0-alpha.4 (2022-10-14) From b6dda28e0cee0f96059bfe91ed43b74052cf6407 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Fri, 28 Oct 2022 16:12:46 +0200 Subject: [PATCH 14/15] hacky fix for storybook --- .../react-ui/src/widgets/ComparativeFormulaWidgetUI.js | 4 +++- .../widgetsUI/ComparativeFormulaWidgetUI.stories.js | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index 2dccf6810..414549439 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -37,7 +37,8 @@ const useStyles = makeStyles((theme) => ({ })); /** - * Renders a widget + * Renders a `` widget + * */ function ComparativeFormulaWidgetUI({ data = EMPTY_ARRAY, diff --git a/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js index e4bd611be..3389ea4d2 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js @@ -1,5 +1,6 @@ import React from 'react'; import ComparativeFormulaWidgetUI from '../../../src/widgets/ComparativeFormulaWidgetUI'; +import { buildReactPropsAsString } from '../../utils' const options = { title: 'Custom Components/ComparativeFormulaWidgetUI', @@ -9,9 +10,7 @@ const options = { export default options; const Template = (args) => ; - -export const Default = Template.bind({}); -Default.args = { +const sampleProps = { data: [1245, 3435.9], labels: [ { prefix: '$', suffix: ' sales', note: 'label 1' }, @@ -19,3 +18,7 @@ Default.args = { ], colors: [{ note: '#ff9900' }, { note: '#6732a8' }] }; + +export const Default = Template.bind({}); +Default.args = sampleProps; +Default.parameters = buildReactPropsAsString(sampleProps, 'ComparativeFormulaWidgetUI'); From dd049f43894cb40701dd380c8eda91c43c378ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Velarde?= Date: Fri, 28 Oct 2022 16:27:45 +0200 Subject: [PATCH 15/15] Remove unused class --- packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index 414549439..8ee653093 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -10,7 +10,6 @@ const IDENTITY_FN = (v) => v; const EMPTY_ARRAY = []; const useStyles = makeStyles((theme) => ({ - formulaChart: {}, formulaGroup: { '& + $formulaGroup': { marginTop: theme.spacing(2) @@ -67,7 +66,7 @@ function ComparativeFormulaWidgetUI({ } return ( -
+
{data .filter((n) => n !== undefined) .map((d, i) => (