diff --git a/packages/vx-axis/Readme.md b/packages/vx-axis/Readme.md index 854507321..8495be803 100644 --- a/packages/vx-axis/Readme.md +++ b/packages/vx-axis/Readme.md @@ -10,6 +10,7 @@ An axis component consists of a line with ticks, tick labels, and an axis label interpret your graph. You can use one of the 4 pre-made axes, or you can create your own based on the `` element. +Note that the `@vx/react-spring` package exports an `AnimatedAxis` variant with animated ticks. ## Installation diff --git a/packages/vx-axis/package.json b/packages/vx-axis/package.json index 7af83a4e2..f275a66aa 100644 --- a/packages/vx-axis/package.json +++ b/packages/vx-axis/package.json @@ -38,9 +38,6 @@ "classnames": "^2.2.5", "prop-types": "^15.6.0" }, - "devDependencies": { - "@vx/scale": "0.0.198" - }, "peerDependencies": { "react": "^16.3.0-0" }, diff --git a/packages/vx-axis/src/axis/Axis.tsx b/packages/vx-axis/src/axis/Axis.tsx index 3571b8093..f8b0c9c78 100644 --- a/packages/vx-axis/src/axis/Axis.tsx +++ b/packages/vx-axis/src/axis/Axis.tsx @@ -46,20 +46,21 @@ export default function Axis({ horizontal, ); - const ticks = (tickValues ?? getTicks(scale, numTicks)) + const filteredTickValues = (tickValues ?? getTicks(scale, numTicks)) .map((value, index) => ({ value, index })) - .filter(({ value }) => !hideZero || (value !== 0 && value !== '0')) - .map(({ value, index }) => { - const scaledValue = coerceNumber(tickPosition(value)); + .filter(({ value }) => !hideZero || (value !== 0 && value !== '0')); - return { - value, - index, - from: createPoint({ x: scaledValue, y: 0 }, horizontal), - to: createPoint({ x: scaledValue, y: tickLength * tickSign }, horizontal), - formattedValue: format(value, index), - }; - }); + const ticks = filteredTickValues.map(({ value, index }) => { + const scaledValue = coerceNumber(tickPosition(value)); + + return { + value, + index, + from: createPoint({ x: scaledValue, y: 0 }, horizontal), + to: createPoint({ x: scaledValue, y: tickLength * tickSign }, horizontal), + formattedValue: format(value, index, filteredTickValues), + }; + }); return ( diff --git a/packages/vx-axis/src/axis/AxisBottom.tsx b/packages/vx-axis/src/axis/AxisBottom.tsx index 9b3269034..89e714668 100644 --- a/packages/vx-axis/src/axis/AxisBottom.tsx +++ b/packages/vx-axis/src/axis/AxisBottom.tsx @@ -4,16 +4,19 @@ import Axis from './Axis'; import Orientation from '../constants/orientation'; import { SharedAxisProps, AxisScale } from '../types'; -export default function AxisBottom({ - axisClassName, - labelOffset = 8, - tickLabelProps = (/** tickValue, index */) => ({ +export const bottomTickLabelProps = (/** tickValue, index */) => + ({ dy: '0.25em', fill: '#222', fontFamily: 'Arial', fontSize: 10, textAnchor: 'middle', - }), + } as const); + +export default function AxisBottom({ + axisClassName, + labelOffset = 8, + tickLabelProps = bottomTickLabelProps, tickLength = 8, ...restProps }: SharedAxisProps) { diff --git a/packages/vx-axis/src/axis/AxisLeft.tsx b/packages/vx-axis/src/axis/AxisLeft.tsx index d6ccdebf6..a9c307855 100644 --- a/packages/vx-axis/src/axis/AxisLeft.tsx +++ b/packages/vx-axis/src/axis/AxisLeft.tsx @@ -4,17 +4,20 @@ import Axis from './Axis'; import Orientation from '../constants/orientation'; import { SharedAxisProps, AxisScale } from '../types'; -export default function AxisLeft({ - axisClassName, - labelOffset = 36, - tickLabelProps = (/** tickValue, index */) => ({ +export const leftTickLabelProps = (/** tickValue, index */) => + ({ dx: '-0.25em', dy: '0.25em', fill: '#222', fontFamily: 'Arial', fontSize: 10, textAnchor: 'end', - }), + } as const); + +export default function AxisLeft({ + axisClassName, + labelOffset = 36, + tickLabelProps = leftTickLabelProps, tickLength = 8, ...restProps }: SharedAxisProps) { diff --git a/packages/vx-axis/src/axis/AxisRenderer.tsx b/packages/vx-axis/src/axis/AxisRenderer.tsx index 594ad238d..7355ec97c 100644 --- a/packages/vx-axis/src/axis/AxisRenderer.tsx +++ b/packages/vx-axis/src/axis/AxisRenderer.tsx @@ -1,13 +1,12 @@ import React from 'react'; import cx from 'classnames'; import { Line } from '@vx/shape'; -import { Group } from '@vx/group'; import { Text } from '@vx/text'; import { TextProps } from '@vx/text/lib/Text'; -import Orientation from '../constants/orientation'; import getLabelTransform from '../utils/getLabelTransform'; import { AxisRendererProps, AxisScale } from '../types'; +import Ticks from './Ticks'; const defaultTextProps: Partial = { textAnchor: 'middle', @@ -30,8 +29,8 @@ export default function AxisRenderer({ orientation, scale, stroke = '#222', - strokeWidth = 1, strokeDasharray, + strokeWidth = 1, tickClassName, tickComponent, tickLabelProps = (/** tickValue, index */) => defaultTextProps, @@ -39,42 +38,27 @@ export default function AxisRenderer({ tickStroke = '#222', tickTransform, ticks, + ticksComponent = Ticks, }: AxisRendererProps) { - let tickLabelFontSize = 10; // track the max tick label size to compute label offset - + // compute the max tick label size to compute label offset + const allTickLabelProps = ticks.map(({ value, index }) => tickLabelProps(value, index)); + const maxTickLabelFontSize = Math.max( + 10, + ...allTickLabelProps.map(props => (typeof props.fontSize === 'number' ? props.fontSize : 0)), + ); return ( <> - {ticks.map(({ value, index, from, to, formattedValue }) => { - const tickLabelPropsObj = tickLabelProps(value, index); - tickLabelFontSize = Math.max( - tickLabelFontSize, - (typeof tickLabelPropsObj.fontSize === 'number' && tickLabelPropsObj.fontSize) || 0, - ); - - const tickYCoord = - to.y + (horizontal && orientation !== Orientation.top ? tickLabelFontSize : 0); - - return ( - - {!hideTicks && } - {tickComponent ? ( - tickComponent({ - ...tickLabelPropsObj, - x: to.x, - y: tickYCoord, - formattedValue, - }) - ) : ( - - {formattedValue} - - )} - - ); + {ticksComponent({ + hideTicks, + horizontal, + orientation, + scale, + tickClassName, + tickComponent, + tickLabelProps: allTickLabelProps, + tickStroke, + tickTransform, + ticks, })} {!hideAxisLine && ( @@ -96,7 +80,7 @@ export default function AxisRenderer({ labelProps, orientation, range: scale.range(), - tickLabelFontSize, + tickLabelFontSize: maxTickLabelFontSize, tickLength, })} {...labelProps} diff --git a/packages/vx-axis/src/axis/AxisRight.tsx b/packages/vx-axis/src/axis/AxisRight.tsx index 636c026b6..03cb6df2e 100644 --- a/packages/vx-axis/src/axis/AxisRight.tsx +++ b/packages/vx-axis/src/axis/AxisRight.tsx @@ -6,17 +6,20 @@ import { SharedAxisProps, AxisScale } from '../types'; export type AxisRightProps = SharedAxisProps; -export default function AxisRight({ - axisClassName, - labelOffset = 36, - tickLabelProps = (/** tickValue, index */) => ({ +export const rightTickLabelProps = (/** tickValue, index */) => + ({ dx: '0.25em', dy: '0.25em', fill: '#222', fontFamily: 'Arial', fontSize: 10, textAnchor: 'start', - }), + } as const); + +export default function AxisRight({ + axisClassName, + labelOffset = 36, + tickLabelProps = rightTickLabelProps, tickLength = 8, ...restProps }: AxisRightProps) { diff --git a/packages/vx-axis/src/axis/AxisTop.tsx b/packages/vx-axis/src/axis/AxisTop.tsx index 11ef9570f..40ff2f1fd 100644 --- a/packages/vx-axis/src/axis/AxisTop.tsx +++ b/packages/vx-axis/src/axis/AxisTop.tsx @@ -6,16 +6,19 @@ import { SharedAxisProps, AxisScale } from '../types'; export type AxisTopProps = SharedAxisProps; -export default function AxisTop({ - axisClassName, - labelOffset = 8, - tickLabelProps = (/** tickValue, index */) => ({ - dy: '-0.25em', +export const topTickLabelProps = (/** tickValue, index */) => + ({ + dy: '-0.75em', fill: '#222', fontFamily: 'Arial', fontSize: 10, textAnchor: 'middle', - }), + } as const); + +export default function AxisTop({ + axisClassName, + labelOffset = 8, + tickLabelProps = topTickLabelProps, tickLength = 8, ...restProps }: AxisTopProps) { diff --git a/packages/vx-axis/src/axis/Ticks.tsx b/packages/vx-axis/src/axis/Ticks.tsx new file mode 100644 index 000000000..e576c0cdd --- /dev/null +++ b/packages/vx-axis/src/axis/Ticks.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import cx from 'classnames'; +import { Line } from '@vx/shape'; +import { Group } from '@vx/group'; +import { Text } from '@vx/text'; + +import Orientation from '../constants/orientation'; +import { TicksRendererProps, AxisScale } from '../types'; + +export default function Ticks({ + hideTicks, + horizontal, + orientation, + tickClassName, + tickComponent, + tickLabelProps: allTickLabelProps, + tickStroke = '#222', + tickTransform, + ticks, +}: TicksRendererProps) { + return ticks.map(({ value, index, from, to, formattedValue }) => { + const tickLabelProps = allTickLabelProps[index] ?? {}; + const tickLabelFontSize = Math.max( + 10, + (typeof tickLabelProps.fontSize === 'number' && tickLabelProps.fontSize) || 0, + ); + + const tickYCoord = + to.y + (horizontal && orientation !== Orientation.top ? tickLabelFontSize : 0); + + return ( + + {!hideTicks && } + {tickComponent ? ( + tickComponent({ + ...tickLabelProps, + x: to.x, + y: tickYCoord, + formattedValue, + }) + ) : ( + + {formattedValue} + + )} + + ); + }); +} diff --git a/packages/vx-axis/src/types.ts b/packages/vx-axis/src/types.ts index c9ed57652..a9c9b486c 100644 --- a/packages/vx-axis/src/types.ts +++ b/packages/vx-axis/src/types.ts @@ -13,7 +13,11 @@ export type AxisScale = type FormattedValue = string | undefined; -export type TickFormatter = (value: T, index: number) => FormattedValue; +export type TickFormatter = ( + value: T, + index: number, + values: { value: T; index: number }[], +) => FormattedValue; export type TickLabelProps = (value: T, index: number) => Partial; @@ -23,6 +27,21 @@ export type TickRendererProps = Partial & { formattedValue: FormattedValue; }; +export type TicksRendererProps = { + tickLabelProps: Partial[]; +} & Pick< + AxisRendererProps, + | 'hideTicks' + | 'horizontal' + | 'orientation' + | 'scale' + | 'tickClassName' + | 'tickComponent' + | 'tickStroke' + | 'tickTransform' + | 'ticks' +>; + interface CommonProps { /** The class name applied to the axis line element. */ axisLineClassName?: string; @@ -54,8 +73,10 @@ interface CommonProps { strokeDasharray?: string; /** The class name applied to each tick group. */ tickClassName?: string; - /** Override the component used to render tick labels (instead of from @vx/text) */ + /** Override the component used to render tick labels (instead of from @vx/text). */ tickComponent?: (tickRendererProps: TickRendererProps) => React.ReactNode; + /** Override the component used to render all tick lines and labels. */ + ticksComponent?: (tickRendererProps: TicksRendererProps) => React.ReactNode; /** A [d3 formatter](https://github.com/d3/d3-scale/blob/master/README.md#continuous_tickFormat) for the tick text. */ tickFormat: TickFormatter>; /** A function that returns props for a given tick label. */ @@ -73,6 +94,14 @@ interface Point { y: number; } +export type ComputedTick = { + value: ScaleInput; + index: number; + from: Point; + to: Point; + formattedValue: FormattedValue; +}; + export type AxisRendererProps = CommonProps & { /** Start point of the axis line */ axisFromPoint: Point; @@ -87,13 +116,7 @@ export type AxisRendererProps = CommonProps & { /** Axis coordinate sign, -1 for left or top orientation. */ tickSign: 1 | -1; /** Computed ticks with positions and formatted value */ - ticks: { - value: ScaleInput; - index: number; - from: Point; - to: Point; - formattedValue: FormattedValue; - }[]; + ticks: ComputedTick[]; }; export type SharedAxisProps = Partial> & { diff --git a/packages/vx-demo/package.json b/packages/vx-demo/package.json index 953979e16..5e07f8b58 100644 --- a/packages/vx-demo/package.json +++ b/packages/vx-demo/package.json @@ -44,6 +44,7 @@ "@vx/network": "0.0.198", "@vx/pattern": "0.0.198", "@vx/point": "0.0.198", + "@vx/react-spring": "0.0.198", "@vx/responsive": "0.0.198", "@vx/scale": "0.0.198", "@vx/shape": "0.0.198", diff --git a/packages/vx-demo/src/components/Gallery/AxisTile.tsx b/packages/vx-demo/src/components/Gallery/AxisTile.tsx index 2bfd66de0..0793dbd5e 100644 --- a/packages/vx-demo/src/components/Gallery/AxisTile.tsx +++ b/packages/vx-demo/src/components/Gallery/AxisTile.tsx @@ -6,6 +6,7 @@ export { default as packageJson } from '../../sandboxes/vx-axis/package.json'; const tileStyles = { backgroundColor }; const detailsStyles = { color: labelColor }; +const exampleProps = { showControls: false }; export default function AxisTile() { return ( @@ -13,6 +14,7 @@ export default function AxisTile() { title="Axes & scales" description="" detailsStyles={detailsStyles} + exampleProps={exampleProps} exampleRenderer={Axis} exampleUrl="/axis" tileStyles={tileStyles} diff --git a/packages/vx-demo/src/components/Gallery/index.tsx b/packages/vx-demo/src/components/Gallery/index.tsx index 034c1a9db..195b4f15e 100644 --- a/packages/vx-demo/src/components/Gallery/index.tsx +++ b/packages/vx-demo/src/components/Gallery/index.tsx @@ -96,11 +96,11 @@ const tiles = [ export default function Gallery() { const router = useRouter(); - const { pkg } = router.query; + const { pkg: routePackage } = router.query; - const filteredTiles = pkg + const filteredTiles = routePackage ? tiles.filter(Tile => - exampleToVxDependencyLookup[Tile.packageJson.name]?.has(pkg as VxPackage), + exampleToVxDependencyLookup[Tile.packageJson.name]?.has(routePackage as VxPackage), ) : tiles; @@ -110,10 +110,16 @@ export default function Gallery() {
Examples by package
{vxPackages.map(vxPackage => ( - + {`@vx/${vxPackage}`} diff --git a/packages/vx-demo/src/components/PackageList.tsx b/packages/vx-demo/src/components/PackageList.tsx index 073e977dc..ced0cffc6 100644 --- a/packages/vx-demo/src/components/PackageList.tsx +++ b/packages/vx-demo/src/components/PackageList.tsx @@ -114,6 +114,12 @@ export default function PackageList({ {!compact &&

Visualize nodes and links between them

} +
  • + + @vx/react-spring + + {!compact &&

    Animated vx primitives

    } +
  • @vx/stats diff --git a/packages/vx-demo/src/pages/docs/react-spring.tsx b/packages/vx-demo/src/pages/docs/react-spring.tsx new file mode 100644 index 000000000..f8bb8721f --- /dev/null +++ b/packages/vx-demo/src/pages/docs/react-spring.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactSpringReadme from '!!raw-loader!../../../../vx-react-spring/README.md'; +import AnimatedAxis from '../../../../vx-react-spring/src/axis/AnimatedAxis'; +import DocPage from '../../components/DocPage'; +import AxisTile from '../../components/Gallery/AxisTile'; + +const components = [AnimatedAxis]; + +const examples = [AxisTile]; + +export default () => ( + +); diff --git a/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx b/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx index 9ed6f95c7..58b729d75 100644 --- a/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx +++ b/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import AreaClosed from '@vx/shape/lib/shapes/AreaClosed'; import { Grid } from '@vx/grid'; import { curveMonotoneX } from '@vx/curve'; -import { scaleUtc, scaleLinear, scaleLog, scaleBand, ScaleInput } from '@vx/scale'; -import { AxisBottom, SharedAxisProps, AxisScale } from '@vx/axis'; +import { scaleUtc, scaleLinear, scaleLog, scaleBand, ScaleInput, coerceNumber } from '@vx/scale'; +import { Orientation, SharedAxisProps, AxisScale } from '@vx/axis'; +import { AnimatedAxis } from '@vx/react-spring'; import { LinearGradient } from '@vx/gradient'; import { timeFormat } from 'd3-time-format'; @@ -20,15 +21,26 @@ const margin = { left: 50, }; +const getMinMax = (vals: (number | { valueOf(): number })[]) => { + const numericVals = vals.map(coerceNumber); + return [Math.min(...numericVals), Math.max(...numericVals)]; +}; + export type AxisProps = { width: number; height: number; + showControls?: boolean; }; -export default function Example({ width: outerWidth = 800, height: outerHeight = 800 }: AxisProps) { +export default function Example({ + width: outerWidth = 800, + height: outerHeight = 800, + showControls = true, +}: AxisProps) { // in svg, margin is subtracted from total width/height const width = outerWidth - margin.left - margin.right; const height = outerHeight - margin.top - margin.bottom; + const [dataToggle, setDataToggle] = useState(true); if (width < 10) return null; @@ -36,44 +48,57 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = values: ScaleInput[]; } + // toggle between two value ranges to demo animation + const linearValues = dataToggle ? [0, 2, 4, 6, 8, 10] : [6, 8, 10, 12]; + const bandValues = dataToggle ? ['a', 'b', 'c', 'd'] : ['d', 'c', 'b', 'a']; + const timeValues = dataToggle + ? [new Date('2020-01-01'), new Date('2020-02-01')] + : [new Date('2020-02-01'), new Date('2020-03-01')]; + const logValues = dataToggle ? [1, 10, 100, 1000, 10000] : [0.0001, 0.001, 0.1, 1, 10, 100]; + const axes: AxisDemoProps>[] = [ { scale: scaleLinear({ - domain: [0, 10], + domain: getMinMax(linearValues), range: [0, width], }), - values: [0, 2, 4, 6, 8, 10], - tickFormat: (v: number) => (v === 10 ? 'last' : (v === 0 && 'first') || `${v}`), + values: linearValues, + tickFormat: (v: number, index: number, ticks: { value: number; index: number }[]) => + index === 0 ? 'first' : index === ticks[ticks.length - 1].index ? 'last' : `${v}`, label: 'linear', }, { scale: scaleBand({ - domain: ['a', 'b', 'c', 'd'], + domain: bandValues, range: [0, width], paddingOuter: 0, paddingInner: 1, }), - values: ['a', 'b', 'c', 'd'], + values: bandValues, tickFormat: (v: string) => v, label: 'categories', }, { scale: scaleUtc({ - domain: [new Date('2020-01-01'), new Date('2020-03-01')], + domain: getMinMax(timeValues), range: [0, width], }), - values: [new Date('2020-01-01'), new Date('2020-02-01'), new Date('2020-03-01')], + values: timeValues, tickFormat: (v: Date, i: number) => - v.getDate() === 1 ? '🎉' : width > 400 || i % 2 === 0 ? timeFormat('%b %d')(v) : '', + i === 3 ? '🎉' : width > 400 || i % 2 === 0 ? timeFormat('%b %d')(v) : '', label: 'time', }, { scale: scaleLog({ - domain: [1, 10000], + domain: getMinMax(logValues), range: [0, width], }), - values: [1, 10, 100, 1000, 10000], - tickFormat: (v: number) => (`${v}`[0] === '1' ? `${v}` : ''), + values: logValues, + tickFormat: (v: number) => { + const asString = `${v}`; + // label only major ticks + return asString.match(/^[.01?[\]]*$/) ? asString : ''; + }, label: 'log', }, ]; @@ -87,76 +112,80 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = }); return ( - - - - - {axes.map(({ scale, values, label, tickFormat }, i) => ( - - [ - (scale(x) ?? 0) + - ('bandwidth' in scale && typeof scale!.bandwidth !== 'undefined' - ? scale.bandwidth!() / 2 - : 0), - yScale(10 + Math.random() * 90), - ])} - yScale={yScale} - curve={curveMonotoneX} - fill={gridColor} - fillOpacity={0.2} - /> - - ({ + <> + + + + + {axes.map(({ scale, values, label, tickFormat }, i) => ( + + [ + (scale(x) ?? 0) + + ('bandwidth' in scale && typeof scale!.bandwidth !== 'undefined' + ? scale.bandwidth!() / 2 + : 0), + yScale(10 + Math.random() * 90), + ])} + yScale={yScale} + curve={curveMonotoneX} + fill={gridColor} + fillOpacity={0.2} + /> + + ({ fill: tickLabelColor, fontSize: 12, fontFamily: 'sans-serif', textAnchor: 'middle', - }) - } - label={label} - labelProps={{ - x: width + 30, - y: -10, - fill: labelColor, - fontSize: 18, - strokeWidth: 0, - stroke: '#fff', - paintOrder: 'stroke', - fontFamily: 'sans-serif', - textAnchor: 'start', - }} - /> - - ))} - - + })} + tickValues={label === 'log' || label === 'time' ? undefined : values} + numTicks={label === 'time' ? 6 : undefined} + label={label} + labelProps={{ + x: width + 30, + y: -10, + fill: labelColor, + fontSize: 18, + strokeWidth: 0, + stroke: '#fff', + paintOrder: 'stroke', + fontFamily: 'sans-serif', + textAnchor: 'start', + }} + /> + + ))} + + + {showControls && } + ); } diff --git a/packages/vx-demo/src/sandboxes/vx-axis/package.json b/packages/vx-demo/src/sandboxes/vx-axis/package.json index d7341ea03..ad88de0df 100644 --- a/packages/vx-demo/src/sandboxes/vx-axis/package.json +++ b/packages/vx-demo/src/sandboxes/vx-axis/package.json @@ -14,6 +14,7 @@ "@vx/grid": "latest", "@vx/group": "latest", "@vx/mock-data": "latest", + "@vx/react-spring": "latest", "@vx/responsive": "latest", "@vx/shape": "latest", "@vx/scale": "latest", diff --git a/packages/vx-demo/src/types/index.ts b/packages/vx-demo/src/types/index.ts index bd400a275..fa453ce68 100644 --- a/packages/vx-demo/src/types/index.ts +++ b/packages/vx-demo/src/types/index.ts @@ -40,6 +40,7 @@ export type VxPackage = | 'network' | 'pattern' | 'point' + | 'react-spring' | 'responsive' | 'scale' | 'shape' diff --git a/packages/vx-react-spring/README.md b/packages/vx-react-spring/README.md new file mode 100644 index 000000000..cdcc64055 --- /dev/null +++ b/packages/vx-react-spring/README.md @@ -0,0 +1,21 @@ +# @vx/react-spring + + + + + +Although `vx` is largely unopinioned on animation, there is value in abstracting the complexity + +boilerplate of building **animated** `vx` visualization components. This package requires +`react-spring` as a `peerDependency` (to be compatible with any usage within your app) and exports +all `vx` components that depend on `react-spring`. + +[`react-spring`](https://www.react-spring.io/) provides performant primitives for animating react +components and is our recommended library for making animated charts. In order to minimize +`react-spring` as a dependency across other `vx` packages, we've consolidated components which +depend on it here. + +## Installation + +``` +npm install --save react-spring @vx/react-spring +``` diff --git a/packages/vx-react-spring/package.json b/packages/vx-react-spring/package.json new file mode 100644 index 000000000..47e4563ab --- /dev/null +++ b/packages/vx-react-spring/package.json @@ -0,0 +1,53 @@ +{ + "name": "@vx/react-spring", + "version": "0.0.198", + "description": "Vx primitives that rely on react-spring for animation", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib", + "esm" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/hshoff/vx.git" + }, + "keywords": [ + "vx", + "react", + "d3", + "visualizations", + "charts", + "animation", + "react-spring" + ], + "contributors": [ + { + "name": "Chris Williams", + "url": "https://github.com/williaster" + } + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/hshoff/vx/issues" + }, + "homepage": "https://github.com/hshoff/vx#readme", + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "react": "^16.3.0-0", + "react-spring": "^8.0.27" + }, + "dependencies": { + "@types/classnames": "^2.2.9", + "@types/react": "*", + "@vx/axis": "0.0.198", + "@vx/scale": "0.0.198", + "@vx/text": "0.0.198", + "classnames": "^2.2.5", + "prop-types": "^15.6.2" + } +} diff --git a/packages/vx-react-spring/src/axis/AnimatedAxis.tsx b/packages/vx-react-spring/src/axis/AnimatedAxis.tsx new file mode 100644 index 000000000..9a49f5e2c --- /dev/null +++ b/packages/vx-react-spring/src/axis/AnimatedAxis.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import Axis, { AxisProps } from '@vx/axis/lib/axis/Axis'; +import { AxisScale } from '@vx/axis/lib/types'; +import AnimatedTicks from './AnimatedTicks'; + +export default function AnimatedAxis( + axisProps: Omit, 'ticksComponent'>, +) { + return ; +} diff --git a/packages/vx-react-spring/src/axis/AnimatedTicks/index.tsx b/packages/vx-react-spring/src/axis/AnimatedTicks/index.tsx new file mode 100644 index 000000000..128115d32 --- /dev/null +++ b/packages/vx-react-spring/src/axis/AnimatedTicks/index.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { animated, useTransition, interpolate } from 'react-spring'; +import cx from 'classnames'; +import Orientation from '@vx/axis/lib/constants/orientation'; +import { TicksRendererProps, AxisScale } from '@vx/axis/lib/types'; +import { Text } from '@vx/text'; + +import useTickTransitionConfig from './useTickTransitionConfig'; + +export default function AnimatedTicks({ + hideTicks, + horizontal, + orientation, + scale, + tickClassName, + tickLabelProps: allTickLabelProps, + tickStroke = '#222', + tickTransform, + ticks, +}: TicksRendererProps) { + const transitionConfig = useTickTransitionConfig({ horizontal, scale }); + const animatedTicks = useTransition(ticks, tick => `${tick.value}-${horizontal}`, { + unique: true, + ...transitionConfig, + }); + + return animatedTicks.map(({ item, key, props }, index) => { + // @ts-ignore react-spring types only include CSSProperties + const { fromX, toX, fromY, toY, opacity } = props; + const tickLabelProps = allTickLabelProps[index] ?? allTickLabelProps[0] ?? {}; + return ( + + {!hideTicks && ( + + )} + {/** animate the group, not the Text */} + + `translate(${interpolatedX},${interpolatedY + + (orientation === Orientation.bottom && typeof tickLabelProps.fontSize === 'number' + ? tickLabelProps.fontSize ?? 10 + : 0)})`, + )} + opacity={opacity} + > + {item.formattedValue} + + + ); + }); +} diff --git a/packages/vx-react-spring/src/axis/AnimatedTicks/useTickTransitionConfig.ts b/packages/vx-react-spring/src/axis/AnimatedTicks/useTickTransitionConfig.ts new file mode 100644 index 000000000..9a956607e --- /dev/null +++ b/packages/vx-react-spring/src/axis/AnimatedTicks/useTickTransitionConfig.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { coerceNumber } from '@vx/scale'; +import { AxisScale, ComputedTick, TicksRendererProps } from '@vx/axis/lib/types'; + +function enterUpdate({ from, to }: ComputedTick) { + return { + fromX: from.x, + toX: to.x, + fromY: from.y, + toY: to.y, + opacity: 1, + }; +} + +export default function useTickTransitionConfig({ + horizontal, + scale, +}: Pick, 'scale' | 'horizontal'>) { + return useMemo(() => { + const [a, b] = scale.range(); + const isDescending = b != null && a != null && b < a; + const [minPosition, maxPosition] = isDescending ? [b, a] : [a, b]; + const scaleLength = b != null && a != null ? Math.abs(coerceNumber(b) - coerceNumber(a)) : 0; + + const fromLeave = ({ from, to, value }: ComputedTick) => { + const scaledValue = scale(value) ?? 0; + + return { + fromX: horizontal + ? // for top/bottom scales, enter from left or right based on value + scaledValue < scaleLength / 2 + ? minPosition + : maxPosition + : // for left/right scales, don't animate x + from.x, + // same logic as above for the `to` Point + toX: horizontal ? (scaledValue < scaleLength / 2 ? minPosition : maxPosition) : to.x, + // for top/bottom scales, don't animate y + fromY: horizontal + ? // for top/bottom scales, don't animate y + from.y + : // for left/right scales, animate from top or bottom based on value + scaledValue < scaleLength / 2 + ? minPosition + : maxPosition, + // same logic as above for the `to` Point + toY: horizontal ? to.y : scaledValue < scaleLength / 2 ? minPosition : maxPosition, + opacity: 0, + }; + }; + + return { from: fromLeave, leave: fromLeave, enter: enterUpdate, update: enterUpdate }; + }, [horizontal, scale]); +} diff --git a/packages/vx-react-spring/src/index.ts b/packages/vx-react-spring/src/index.ts new file mode 100644 index 000000000..08ee44bf0 --- /dev/null +++ b/packages/vx-react-spring/src/index.ts @@ -0,0 +1,2 @@ +export { default as AnimatedTicks } from './axis/AnimatedTicks'; +export { default as AnimatedAxis } from './axis/AnimatedAxis'; diff --git a/packages/vx-react-spring/test/AnimatedAxis.test.tsx b/packages/vx-react-spring/test/AnimatedAxis.test.tsx new file mode 100644 index 000000000..a64a832f4 --- /dev/null +++ b/packages/vx-react-spring/test/AnimatedAxis.test.tsx @@ -0,0 +1,7 @@ +import { AnimatedAxis } from '../src'; + +describe('AnimatedAxis', () => { + it('should be defined', () => { + expect(AnimatedAxis).toBeDefined(); + }); +}); diff --git a/packages/vx-react-spring/test/AnimatedTicks.test.tsx b/packages/vx-react-spring/test/AnimatedTicks.test.tsx new file mode 100644 index 000000000..a9cfe08c1 --- /dev/null +++ b/packages/vx-react-spring/test/AnimatedTicks.test.tsx @@ -0,0 +1,7 @@ +import { AnimatedTicks } from '../src'; + +describe('AnimatedTicks', () => { + it('should be defined', () => { + expect(AnimatedTicks).toBeDefined(); + }); +}); diff --git a/packages/vx-react-spring/test/useTickTransitionConfig.test.tsx b/packages/vx-react-spring/test/useTickTransitionConfig.test.tsx new file mode 100644 index 000000000..a70d9146c --- /dev/null +++ b/packages/vx-react-spring/test/useTickTransitionConfig.test.tsx @@ -0,0 +1,7 @@ +import useTickTransitionConfig from '../src/axis/AnimatedTicks/useTickTransitionConfig'; + +describe('useTickTransitionConfig', () => { + it('should be defined', () => { + expect(useTickTransitionConfig).toBeDefined(); + }); +});