diff --git a/packages/vx-axis/src/types.ts b/packages/vx-axis/src/types.ts index 5e91f5e0f..a0300e847 100644 --- a/packages/vx-axis/src/types.ts +++ b/packages/vx-axis/src/types.ts @@ -44,7 +44,7 @@ export type SharedAxisProps = { /** The color for the stroke of the lines. */ stroke?: string; /** The pixel value for the width of the lines. */ - strokeWidth?: number; + strokeWidth?: string | number; /** The pattern of dashes in the stroke. */ strokeDasharray?: string; /** The class name applied to each tick group. */ diff --git a/packages/vx-demo/package.json b/packages/vx-demo/package.json index e473fc147..afe37cbaa 100644 --- a/packages/vx-demo/package.json +++ b/packages/vx-demo/package.json @@ -78,6 +78,7 @@ "react-markdown": "^4.3.1", "react-spring": "^8.0.27", "react-tilt": "^0.1.4", + "react-use-measure": "^2.0.1", "recompose": "^0.26.0", "topojson-client": "^3.0.0" }, diff --git a/packages/vx-demo/src/pages/XYChart.tsx b/packages/vx-demo/src/pages/XYChart.tsx new file mode 100644 index 000000000..1071f0003 --- /dev/null +++ b/packages/vx-demo/src/pages/XYChart.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Show from '../components/Show'; +import XYChart from '../sandboxes/vx-chart-poc/Example'; +import XYChartSource from '!!raw-loader!../sandboxes/vx-chart-poc/Example'; + +export default () => ( + + {XYChartSource} + +); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/Example.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/Example.tsx new file mode 100644 index 000000000..bf8f2a260 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/Example.tsx @@ -0,0 +1,585 @@ +/* eslint-disable unicorn/consistent-function-scoping */ +import React, { useState, useMemo } from 'react'; +import cityTemperature, { CityTemperature } from '@vx/mock-data/lib/mocks/cityTemperature'; +import defaultTheme from './src/theme/default'; +import darkTheme from './src/theme/darkTheme'; +import Axis from './src/components/Axis'; +import AnimatedAxis from './src/components/AnimatedAxis'; +import ChartProvider from './src/components/providers/ChartProvider'; +import XYChart from './src/components/XYChart'; +import BarSeries from './src/components/series/BarSeries'; +import LineSeries from './src/components/series/LineSeries'; +import ChartBackground from './src/components/ChartBackground'; +import EventProvider from './src/components/providers/TooltipProvider'; +import Tooltip, { RenderTooltipArgs } from './src/components/Tooltip'; +import { ScaleConfig } from './src/types'; +import Legend from './src/components/Legend'; +import CustomLegendShape from './src/components/CustomLegendShape'; +import Group from './src/components/series/Group'; +import Stack from './src/components/series/Stack'; + +type DataKeys = 'austin' | 'sf' | 'ny'; + +const data = cityTemperature.slice(100, 100 + 16); + +// @TODO wip updating data, not currently used +// const halfData = data.slice(0, Math.floor(data.length / 2)); + +const numDateTicks = 5; + +const getDate = (d: CityTemperature) => new Date(d.date); +const getSfTemperature = (d: CityTemperature) => Number(d['San Francisco']); +const getNyTemperature = (d: CityTemperature) => Number(d['New York']); +const getAustinTemperature = (d: CityTemperature) => Number(d.Austin); + +const axisTopMargin = { top: 40, right: 50, bottom: 30, left: 50 }; +const axisBottomMargin = { top: 30, right: 50, bottom: 40, left: 50 }; +const legendLabelFormat = (d: DataKeys) => + d === 'sf' ? 'San Francisco' : d === 'ny' ? 'New York' : d === 'austin' ? 'Austin' : d; + +const renderTooltip = ({ + closestData, + closestDatum, + colorScale, +}: RenderTooltipArgs) => ( + <> +
{closestDatum.datum.date}
+
+ {closestData?.sf && closestDatum.datum.date === closestData.sf.datum.date && ( +
+ San Francisco {closestData.sf.datum['San Francisco']}°F +
+ )} + {closestData?.ny && closestDatum.datum.date === closestData.ny.datum.date && ( +
+ New York {closestData.ny.datum['New York']}°F +
+ )} + {closestData?.austin && closestDatum.datum.date === closestData.austin.datum.date && ( +
+ Austin {closestData.austin.datum.Austin}°F +
+ )} + +); + +/** memoize the accessor functions to prevent re-registering data. */ +function useAccessors( + temperatureAccessor: (d: CityTemperature) => number, + dataMultiplier: number, + renderHorizontally: boolean, + negativeValues: boolean, +) { + return useMemo( + () => ({ + xAccessor: (d: CityTemperature) => + renderHorizontally + ? (negativeValues ? -1 : 1) * dataMultiplier * temperatureAccessor(d) + : getDate(d), + yAccessor: (d: CityTemperature) => + renderHorizontally + ? getDate(d) + : (negativeValues ? -1 : 1) * dataMultiplier * temperatureAccessor(d), + }), + [renderHorizontally, negativeValues, dataMultiplier, temperatureAccessor], + ); +} + +export default function Example() { + const [theme, setTheme] = useState<'light' | 'dark' | 'none'>('light'); + const [useCustomDomain, setUseCustomDomain] = useState(false); + const [currData, setCurrData] = useState(data); + const [useAnimatedAxes, setUseAnimatedAxes] = useState(false); + const [autoWidth, setAutoWidth] = useState(false); + const [renderHorizontally, setRenderHorizontally] = useState(false); + const [negativeValues, setNegativeValues] = useState(false); + const [includeZero, setIncludeZero] = useState(false); + const [xAxisOrientation, setXAxisOrientation] = useState<'top' | 'bottom'>('bottom'); + const [yAxisOrientation, setYAxisOrientation] = useState<'left' | 'right'>('left'); + const [legendLeftRight, setLegendLeftRight] = useState<'left' | 'right'>('right'); + const [legendTopBottom, setLegendTopBottom] = useState<'top' | 'bottom'>('top'); + const [legendDirection, setLegendDirection] = useState<'column' | 'row'>('row'); + const [legendShape, setLegendShape] = useState<'auto' | 'rect' | 'line' | 'circle' | 'custom'>( + 'auto', + ); + const [snapTooltipToDataX, setSnapTooltipToDataX] = useState(true); + const [snapTooltipToDataY, setSnapTooltipToDataY] = useState(true); + const [dataMultiplier, setDataMultiplier] = useState(1); + const [renderTooltipInPortal, setRenderTooltipInPortal] = useState(true); + const [visibleSeries, setVisibleSeries] = useState< + ('line' | 'bar' | 'groupedbar' | 'stackedbar')[] + >(['bar']); + const canSnapTooltipToDataX = + (visibleSeries.includes('groupedbar') && renderHorizontally) || + (visibleSeries.includes('stackedbar') && !renderHorizontally) || + visibleSeries.includes('bar'); + + const canSnapTooltipToDataY = + (visibleSeries.includes('groupedbar') && !renderHorizontally) || + (visibleSeries.includes('stackedbar') && renderHorizontally) || + visibleSeries.includes('bar'); + + const dateScaleConfig: ScaleConfig = useMemo(() => ({ type: 'band', padding: 0.2 }), []); + const temperatureScaleConfig: ScaleConfig = useMemo( + () => ({ + type: 'linear', + clamp: true, + nice: true, + domain: useCustomDomain ? (negativeValues ? [-100, 50] : [-50, 100]) : undefined, + includeZero, + }), + [useCustomDomain, includeZero, negativeValues], + ); + const colorScaleConfig: { domain: DataKeys[] } = useMemo( + () => ({ + domain: + visibleSeries.includes('bar') && !visibleSeries.includes('line') + ? ['austin'] + : ['austin', 'sf', 'ny'], + }), + [visibleSeries], + ); + const austinAccessors = useAccessors( + getAustinTemperature, + dataMultiplier, + renderHorizontally, + negativeValues, + ); + const sfAccessors = useAccessors(getSfTemperature, 1, renderHorizontally, false); + const nyAccessors = useAccessors( + getNyTemperature, + dataMultiplier, + renderHorizontally, + negativeValues, + ); + const themeObj = useMemo( + () => + theme === 'light' + ? { ...defaultTheme, colors: ['#fbd46d', '#ff9c71', '#654062'] } + : theme === 'dark' + ? { ...darkTheme, colors: ['#916dd5', '#f8615a', '#ffd868'] } + : // @ts-ignore {} is not a valid theme + { colors: ['#222', '#767676', '#bbb'] }, + [theme], + ); + + const AxisComponent = useAnimatedAxes ? AnimatedAxis : Axis; + + const legend = ( + + ); + + return ( +
+ {/** @ts-ignore */} + + + {legendTopBottom === 'top' && legend} +
+ + + + {visibleSeries.includes('bar') && ( + + )} + {visibleSeries.includes('stackedbar') && ( + + + + + + )} + {visibleSeries.includes('groupedbar') && ( + + + + + + )} + + {visibleSeries.includes('line') && ( + <> + + + + )} + + {/** Temperature axis */} + + {/** Date axis */} + i % Math.round((arr.length - 1) / numDateTicks) === 0) + .map(d => new Date(d.date))} + tickFormat={(d: Date) => d.toISOString?.().split?.('T')[0] ?? d.toString()} + /> + + + {legendTopBottom === 'bottom' && legend} +
+
+
+
+
+
+ data +     + +     + +     + {!useCustomDomain && ( + + )} + +     + +
+
+ theme + + + +
+ +
+ tooltip    + + + +
+
+ legend + + +      + + +      + + +
+
+ legend shape +      + + + + + +
+
+ axis +     + +    + + +      + + +
+ +
+
+ series +     + +    + {/** bar types are mutually exclusive */} + +    + +    + +
+
+ +
+ ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/index.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/index.tsx new file mode 100644 index 000000000..fee92b86d --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/index.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { render } from 'react-dom'; + +import Example from './Example'; +import './sandbox-styles.css'; + +render(, document.getElementById('root')); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/package.json b/packages/vx-demo/src/sandboxes/vx-chart-poc/package.json new file mode 100644 index 000000000..2167ca5d6 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/package.json @@ -0,0 +1,43 @@ +{ + "name": "@vx/xy-chart-example", + "description": "vx xychart proof-of-concept.", + "main": "index.tsx", + "private": true, + "dependencies": { + "@babel/runtime": "^7.8.4", + "@types/d3-array": "^2.0.0", + "@types/lodash": "^4.14.146", + "@types/react": "^16", + "@types/react-dom": "^16", + "@vx/axis": "latest", + "@vx/brush": "latest", + "@vx/group": "latest", + "@vx/event": "latest", + "@vx/legend": "latest", + "@vx/mock-data": "latest", + "@vx/pattern": "latest", + "@vx/responsive": "latest", + "@vx/scale": "latest", + "@vx/shape": "latest", + "@vx/text": "latest", + "@vx/tooltip": "latest", + "@vx/voronoi": "latest", + "d3-array": "2.4.0", + "classnames": "2.2.6", + "lodash": "^4.17.10", + "react": "^16", + "react-dom": "^16", + "react-scripts-ts": "3.1.0", + "react-spring": "^8.0.27", + "react-use-measure": "^2.0.1", + "resize-observer-polyfill": "1.5.1", + "typescript": "^3" + }, + "keywords": [ + "visualization", + "d3", + "react", + "vx", + "chart" + ] +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/sandbox-styles.css b/packages/vx-demo/src/sandboxes/vx-chart-poc/sandbox-styles.css new file mode 100644 index 000000000..b91993723 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/sandbox-styles.css @@ -0,0 +1,8 @@ +html, +body, +#root { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, + 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 2em; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/AnimatedAxis/AnimatedTicks.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/AnimatedAxis/AnimatedTicks.tsx new file mode 100644 index 000000000..23da7128c --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/AnimatedAxis/AnimatedTicks.tsx @@ -0,0 +1,148 @@ +/* eslint-disable unicorn/consistent-function-scoping */ +import React, { useMemo } from 'react'; +import cx from 'classnames'; +import { animated, useTransition, interpolate } from 'react-spring'; +import { AxisProps as BaseAxisProps } from '@vx/axis/lib/axis/Axis'; +import { ChildRenderProps } from '@vx/axis/lib/types'; +import { Text } from '@vx/text'; +import { Margin } from '../../types'; + +type Tick = ChildRenderProps['ticks'][number]; + +type AnimatedTicksProps = { + margin: Margin; + width: number; + height: number; + ticks: ChildRenderProps['ticks']; + tickStroke?: string; + horizontal?: boolean; + scale: BaseAxisProps['scale']; +} & Pick< + BaseAxisProps, + 'orientation' | 'tickLabelProps' | 'tickClassName' | 'hideTicks' +>; + +const defaultTickLabelProps = (/** tickValue, index */) => + ({ + textAnchor: 'middle', + fontFamily: 'Arial', + fontSize: 10, + fill: '#222', + } as const); + +/** Hook that returns memoized config for react-spring's transition from/enter/update/leave */ +function useTickTransitionConfig({ + horizontal, + width, + height, + margin, + scale, +}: Pick, 'horizontal' | 'width' | 'height' | 'margin' | 'scale'>) { + return useMemo(() => { + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const fromLeave = ({ from, to, value }: Tick) => { + const scaledValue = scale(value); + + return { + fromX: horizontal + ? // for top/bottom scales, enter from left or right based on value + scaledValue < (margin.left + innerWidth) / 2 + ? margin.left + : margin.left + innerWidth + : // for left/right scales, don't animate x + from.x, + // same logic as above for the `to` Point + toX: horizontal + ? scaledValue < (margin.left + innerWidth) / 2 + ? margin.left + : margin.left + innerWidth + : to.x, + // for top/bottom scales, don't animate y + fromY: horizontal + ? from.y + : // for top/bottom scales, animate from top or bottom based on value + scaledValue < (margin.top + innerHeight) / 2 + ? margin.top + : margin.top + innerHeight, + toY: horizontal + ? from.y + : scaledValue < (margin.top + innerHeight) / 2 + ? margin.top + : margin.top + innerHeight, + opacity: 0, + }; + }; + + const enterUpdate = ({ from, to }: Tick) => ({ + fromX: from.x, + toX: to.x, + fromY: from.y, + toY: to.y, + opacity: 1, + }); + + return { from: fromLeave, leave: fromLeave, enter: enterUpdate, update: enterUpdate }; + }, [horizontal, width, height, margin, scale]); +} + +export default function AnimatedTicks({ + ticks, + margin, + width, + height, + horizontal, + orientation, + tickLabelProps, + tickClassName, + tickStroke, + scale, + hideTicks, +}: AnimatedTicksProps) { + const transitionConfig = useTickTransitionConfig({ margin, width, height, horizontal, scale }); + const animatedTicks = useTransition(ticks, tick => `${tick.value}-${horizontal}`, { + unique: true, + ...transitionConfig, + }); + + return ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + {animatedTicks.map(({ item, key, props }, index) => { + // @ts-ignore react-spring types don't handle fromX, etc. + const { fromX, toX, fromY, toY, opacity } = props; + const tickLabelPropsObj = (tickLabelProps ?? defaultTickLabelProps)(item.value, index); + return ( + + {!hideTicks && ( + + )} + + `translate(${interpolatedX},${interpolatedY + + (orientation === 'bottom' && typeof tickLabelPropsObj.fontSize === 'number' + ? tickLabelPropsObj.fontSize ?? 10 + : 0)})`, + )} + opacity={opacity} + > + {item.formattedValue} + + + ); + })} + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/AnimatedAxis/index.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/AnimatedAxis/index.tsx new file mode 100644 index 000000000..4989ded56 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/AnimatedAxis/index.tsx @@ -0,0 +1,122 @@ +/* eslint-disable unicorn/consistent-function-scoping */ +import React, { useContext, useMemo } from 'react'; +import cx from 'classnames'; +import BaseAxis, { AxisProps as BaseAxisProps } from '@vx/axis/lib/axis/Axis'; +import getLabelTransform from '@vx/axis/lib/utils/labelTransform'; +import { Text } from '@vx/text'; +import { animated } from 'react-spring'; + +import AnimatedTicks from './AnimatedTicks'; +import ChartContext from '../../context/ChartContext'; +import withDefinedContextScales from '../../enhancers/withDefinedContextScales'; + +type AnimatedAxisProps = Omit, 'scale' | 'children'>; + +const defaultLabelProps = { + textAnchor: 'middle', + fontFamily: 'Arial', + fontSize: 10, + fill: '#222', +} as const; + +function AnimatedAxis(props: AnimatedAxisProps) { + const { theme, xScale, yScale, margin, width, height } = useContext(ChartContext); + const { orientation } = props; + + // The biggest difference between Axes is their label + tick label styles + // we take this from props if specified, else we figure it out from the chart theme + const themeTickStylesKey = + orientation === 'left' || orientation === 'right' ? 'yTickStyles' : 'xTickStyles'; + + const tickStyles = useMemo(() => theme[themeTickStylesKey], [theme, themeTickStylesKey]); + + const tickLabelProps = useMemo(() => { + if (props.tickLabelProps) return props.tickLabelProps; + const themeTickLabelProps = theme?.[themeTickStylesKey]?.label?.[orientation]; + return themeTickLabelProps + ? // by default, wrap tick labels within the allotted margin space + () => ({ ...themeTickLabelProps, width: margin[orientation] }) + : undefined; + }, [theme, props.tickLabelProps, themeTickStylesKey, orientation, margin]); + + // extract axis styles from theme + const themeAxisStylesKey = + orientation === 'left' || orientation === 'right' ? 'yAxisStyles' : 'xAxisStyles'; + + const axisStyles = useMemo(() => theme[themeAxisStylesKey], [theme, themeAxisStylesKey]); + + const topOffset = + orientation === 'bottom' ? height - margin.bottom : orientation === 'top' ? margin.top : 0; + const leftOffset = + orientation === 'left' ? margin.left : orientation === 'right' ? width - margin.right : 0; + + const scale = orientation === 'left' || orientation === 'right' ? yScale : xScale; + + const tickStroke = props.tickStroke ?? tickStyles?.stroke; + const tickLength = props.tickLength ?? tickStyles?.tickLength; + const axisStroke = props.stroke ?? axisStyles?.stroke; + const axisStrokeWidth = props.strokeWidth ?? axisStyles?.strokeWidth; + const axisLabelOffset = props.labelOffset ?? 14; + const axisLabelProps = + (props.labelProps || axisStyles?.label?.[orientation]) ?? defaultLabelProps; + + return ( + + top={topOffset} + left={leftOffset} + {...props} + tickLength={tickLength} + scale={scale} + > + {({ axisFromPoint, axisToPoint, horizontal, ticks }) => ( + <> + + width={width} + margin={margin} + height={height} + ticks={ticks} + horizontal={horizontal} + tickStroke={tickStroke} + tickLabelProps={tickLabelProps} + orientation={orientation} + scale={scale} + hideTicks={props.hideTicks} + tickClassName={props.tickClassName} + /> + + {!props.hideAxisLine && ( + + )} + {props.label && ( + + {props.label} + + )} + + )} + + ); +} + +// AnimatedAxis shouldn't render unless scales are available in context +export default withDefinedContextScales(AnimatedAxis); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Axis.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Axis.tsx new file mode 100644 index 000000000..494be23de --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Axis.tsx @@ -0,0 +1,57 @@ +import React, { useContext, useMemo } from 'react'; +import BaseAxis, { AxisProps as BaseAxisProps } from '@vx/axis/lib/axis/Axis'; + +import ChartContext from '../context/ChartContext'; +import withDefinedContextScales from '../enhancers/withDefinedContextScales'; + +type AxisProps = Omit, 'scale'>; + +function Axis(props: AxisProps) { + const { theme, xScale, yScale, margin, width, height } = useContext(ChartContext); + const { orientation } = props; + + // The biggest difference between Axes is their label + tick label styles + // we take this from props if specified, else we figure it out from the chart theme + const themeTickStylesKey = + orientation === 'left' || orientation === 'right' ? 'yTickStyles' : 'xTickStyles'; + + const tickStyles = useMemo(() => theme[themeTickStylesKey], [theme, themeTickStylesKey]); + + const tickLabelProps = useMemo(() => { + if (props.tickLabelProps) return props.tickLabelProps; + const themeTickLabelProps = theme?.[themeTickStylesKey]?.label?.[orientation]; + + return themeTickLabelProps + ? // by default, wrap tick labels within the allotted margin space + () => ({ ...themeTickLabelProps, width: margin[orientation] }) + : undefined; + }, [theme, props.tickLabelProps, themeTickStylesKey, orientation, margin]); + + // extract axis styles from theme + const themeAxisStylesKey = + orientation === 'left' || orientation === 'right' ? 'yAxisStyles' : 'xAxisStyles'; + + const axisStyles = useMemo(() => theme[themeAxisStylesKey], [theme, themeAxisStylesKey]); + + const topOffset = + orientation === 'bottom' ? height - margin.bottom : orientation === 'top' ? margin.top : 0; + const leftOffset = + orientation === 'left' ? margin.left : orientation === 'right' ? width - margin.right : 0; + + return ( + + top={topOffset} + left={leftOffset} + labelProps={axisStyles?.label?.[orientation]} + stroke={axisStyles?.stroke} + strokeWidth={axisStyles?.strokeWidth} + tickLength={tickStyles?.tickLength} + tickStroke={tickStyles?.stroke} + {...props} + tickLabelProps={tickLabelProps} + scale={orientation === 'left' || orientation === 'right' ? yScale : xScale} + /> + ); +} + +export default withDefinedContextScales(Axis); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Brush.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Brush.tsx new file mode 100644 index 000000000..42ca2cefb --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Brush.tsx @@ -0,0 +1,87 @@ +import React, { useContext } from 'react'; +import BaseBrush, { BrushProps as BaseBrushProps } from '@vx/brush/lib/Brush'; +import { ResizeTriggerAreas } from '@vx/brush/lib/types'; +import ChartContext from '../context/ChartContext'; + +const leftRightResizeTriggers: ResizeTriggerAreas[] = ['left', 'right']; +const topBottomResizeTriggers: ResizeTriggerAreas[] = ['top', 'bottom']; +const allResizeTriggers: ResizeTriggerAreas[] = [ + 'left', + 'right', + 'top', + 'bottom', + 'topLeft', + 'topRight', + 'bottomLeft', + 'bottomRight', +]; + +type BrushProps = Partial< + Pick< + BaseBrushProps, + | 'brushDirection' + | 'brushRegion' + | 'handleSize' + | 'onChange' + | 'onClick' + | 'resizeTriggerAreas' + | 'selectedBoxStyle' + | 'yAxisOrientation' + | 'xAxisOrientation' + > +> & { initialBrushPosition?: (scales) => BaseBrushProps['initialBrushPosition'] }; + +export default function Brush({ + brushDirection = 'horizontal', + brushRegion = 'chart', + handleSize = 8, + initialBrushPosition, + onChange, + onClick, + resizeTriggerAreas, + selectedBoxStyle, + xAxisOrientation, + yAxisOrientation, +}: BrushProps) { + const { xScale, yScale, margin } = useContext(ChartContext) || {}; + + // not yet available in context + if (!xScale || !yScale) return null; + + // @TODO make a util for this + const xRange = xScale.range() as number[]; + const yRange = yScale.range() as number[]; + const width = Math.abs(xRange[1] - xRange[0]); + const height = Math.abs(yRange[1] - yRange[0]); + + return ( + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/ChartBackground.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/ChartBackground.tsx new file mode 100644 index 000000000..0e76c894b --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/ChartBackground.tsx @@ -0,0 +1,33 @@ +import React, { useContext } from 'react'; +import { PatternLines } from '@vx/pattern'; +import ChartContext from '../context/ChartContext'; + +const patternId = 'xy-chart-pattern'; + +export default function CustomChartBackground() { + const { theme, margin, width, height } = useContext(ChartContext); + + // early return if scale is not available in context + if (width == null || height == null || margin == null) return null; + + return ( + <> + + + + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/CustomLegendShape.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/CustomLegendShape.tsx new file mode 100644 index 000000000..b1df136d1 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/CustomLegendShape.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { RenderShapeProvidedProps } from '@vx/legend/src/types'; + +/** Example of rendering of a custom legend shape */ +export default function CustomLegendShape({ + itemIndex, + fill, + size, +}: RenderShapeProvidedProps) { + return ( +
+ {[...new Array(itemIndex + 1)].map(() => '$')} +
+ ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Legend.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Legend.tsx new file mode 100644 index 000000000..ca1f9f1b1 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Legend.tsx @@ -0,0 +1,73 @@ +import React, { useContext, useCallback, useMemo } from 'react'; +import BaseLegend, { LegendProps as BaseLegendProps } from '@vx/legend/lib/legends/Legend'; +import Rect from '@vx/legend/lib/shapes/Rect'; +import Circle from '@vx/legend/lib/shapes/Circle'; +import Line from '@vx/legend/lib/shapes/Line'; + +import ChartContext from '../context/ChartContext'; + +// convenience exports to support easy renderShape overrides +export const RectShape = Rect; +export const LineShape = Line; +export const CircleShape = Circle; + +export type LegendProps = { horizontalAlign?: boolean } & Partial>; + +export default function Legend({ + alignLeft = true, + direction = 'row', + shape: Shape, + style, + ...props +}: LegendProps) { + const { theme, margin, colorScale, dataRegistry } = useContext(ChartContext); + const legendLabelProps = useMemo(() => ({ style: { ...theme.labelStyles } }), [theme]); + const legendStyles = useMemo( + () => ({ + display: 'flex', + background: theme?.baseColor ?? 'white', + color: theme?.labelStyles?.fill, + paddingLeft: margin.left, + paddingRight: margin.right, + [direction === 'row' || direction === 'row-reverse' + ? 'justifyContent' + : 'alignItems']: alignLeft ? 'flex-start' : 'flex-end', + style, + }), + [theme, margin, alignLeft, direction, style], + ); + const renderShape = useCallback( + shapeProps => { + if (Shape && typeof Shape !== 'string') return ; + + const legendShape = Shape || dataRegistry?.[shapeProps.item]?.legendShape; + switch (legendShape) { + case 'circle': + return ; + case 'line': + return ; + case 'dashed-line': + return ( + + ); + case 'rect': + default: + return ; + } + }, + [dataRegistry, Shape], + ); + + return props.scale || colorScale ? ( + + ) : null; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/LegendLineShape.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/LegendLineShape.tsx new file mode 100644 index 000000000..944bd0027 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/LegendLineShape.tsx @@ -0,0 +1,30 @@ +// @TODO move this to @vx/legend +import React from 'react'; +import { Group } from '@vx/group'; + +export type ShapeShapeLineProps = { + fill?: string; + width?: string | number; + height?: string | number; + style?: React.CSSProperties; +}; + +export default function ShapeLine({ fill, width, height, style }: ShapeShapeLineProps) { + const cleanHeight = typeof height === 'string' || typeof height === 'undefined' ? 0 : height; + const lineThickness = Number(style?.strokeWidth ?? 2); + return ( + + + + + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Tooltip.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Tooltip.tsx new file mode 100644 index 000000000..8f90d6577 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/Tooltip.tsx @@ -0,0 +1,91 @@ +import React, { useContext } from 'react'; +import { TooltipWithBounds, Portal, defaultStyles } from '@vx/tooltip'; +import { scaleOrdinal } from '@vx/scale'; + +import TooltipContext from '../context/TooltipContext'; +import ChartContext from '../context/ChartContext'; +import { TooltipData } from '../types'; + +export type RenderTooltipArgs = TooltipData & { + colorScale: typeof scaleOrdinal; +}; + +export type TooltipProps = { + renderTooltip: (d: RenderTooltipArgs) => React.ReactNode; + snapToDataX?: boolean; + snapToDataY?: boolean; + showVerticalCrosshair?: boolean; + /** Whether the tooltip should be rendered in a React portal instead of the React element's parent DOM container */ + renderInPortal?: boolean; +}; + +export default function Tooltip({ + renderTooltip, + snapToDataX, + snapToDataY, + showVerticalCrosshair = true, + renderInPortal = true, +}: TooltipProps) { + const { tooltipData } = useContext(TooltipContext) || {}; + const { margin, xScale, yScale, colorScale, dataRegistry, height, theme } = + useContext(ChartContext) || {}; + + // early return if there's no tooltip + const { closestDatum, svgMouseX, svgMouseY, pageX, pageY, svgOriginX, svgOriginY } = + tooltipData || {}; + + if (!closestDatum || svgMouseX == null || svgMouseY == null) return null; + + const { xAccessor, yAccessor } = dataRegistry[closestDatum.key]; + + const xCoord = snapToDataX + ? (xScale(xAccessor(closestDatum.datum)) as number) + + (xScale.bandwidth?.() ?? 0) / 2 + + (renderInPortal ? svgOriginX : 0) + : renderInPortal + ? pageX + : svgMouseX; + + const yCoord = snapToDataY + ? (yScale(yAccessor(closestDatum.datum)) as number) - + (yScale.bandwidth?.() ?? 0) / 2 + + (renderInPortal ? svgOriginY : 0) + : renderInPortal + ? pageY + : svgMouseY; + + const Container = renderInPortal ? Portal : React.Fragment; + + return ( + + {/** @TODO not doing this in SVG is jank. Better solution? */} + {yScale && showVerticalCrosshair && ( +
+ )} + + {renderTooltip({ ...tooltipData, colorScale })} + + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/XYChart.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/XYChart.tsx new file mode 100644 index 000000000..4ac1bd88c --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/XYChart.tsx @@ -0,0 +1,71 @@ +import React, { useContext, useEffect, useCallback } from 'react'; +import ParentSize from '@vx/responsive/lib/components/ParentSize'; +import useMeasure from 'react-use-measure'; + +import ChartContext from '../context/ChartContext'; +import { Margin } from '../types'; +import TooltipContext from '../context/TooltipContext'; + +type Props = { + events?: boolean; + width?: number; + height?: number; + margin?: Margin; + children: React.ReactNode; + captureEvents?: boolean; +}; + +export default function XYChart(props: Props) { + const { children, width, height, margin, captureEvents = true } = props; + const { findNearestData, setChartDimensions } = useContext(ChartContext); + const { showTooltip, hideTooltip } = useContext(TooltipContext) || {}; + const [svgRef, svgBounds] = useMeasure(); + + // update dimensions in context + useEffect(() => { + if (width != null && height != null && width > 0 && height > 0) { + setChartDimensions({ width, height, margin }); + } + }, [setChartDimensions, width, height, margin]); + + const onMouseMove = useCallback( + (event: React.MouseEvent) => { + const nearestData = findNearestData(event); + if (nearestData.closestDatum && showTooltip) { + showTooltip({ + tooltipData: { + ...nearestData, + // @TODO remove this and rely on useTooltipInPortal() instead + pageX: event.pageX, + pageY: event.pageY, + svgOriginX: svgBounds?.x, + svgOriginY: svgBounds?.y, + }, + }); + } + }, + [findNearestData, showTooltip, svgBounds], + ); + + // if width and height aren't both provided, wrap in auto-sizer + if (width == null || height == null) { + return {dims => }; + } + + return width > 0 && height > 0 ? ( + + {children} + {captureEvents && ( + + )} + + ) : null; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/providers/ChartProvider.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/providers/ChartProvider.tsx new file mode 100644 index 000000000..1b2b4969e --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/providers/ChartProvider.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { localPoint } from '@vx/event'; +import { scaleOrdinal } from '@vx/scale'; + +import defaultTheme from '../../theme/default'; +import { + ChartContext as ChartContextType, + ChartTheme, + ScaleConfig, + RegisterData, + Margin, + DatumWithKey, + ScaleType, +} from '../../types'; +import ChartContext from '../../context/ChartContext'; +import createScale from '../../createScale'; +import findNearestDatumXY from '../../util/findNearestDatumXY'; + +/** Props that can be passed to initialize/update the provider config. */ +export type ChartProviderProps = { + theme?: ChartTheme; + xScale: ScaleConfig; + yScale: ScaleConfig; + colorScale?: { domain?: string[] }; + children: React.ReactNode; +}; + +type ChartProviderState = Pick< + ChartContextType, + 'xScale' | 'yScale' | 'colorScale' | 'dataRegistry' +> & { + width: number | null; + height: number | null; + margin: Margin; + combinedData: DatumWithKey[]; +}; + +export default class ChartProvider< + Datum = unknown, + XScaleInput = unknown, + YScaleInput = unknown +> extends React.Component< + ChartProviderProps, + ChartProviderState +> { + static defaultProps = { + theme: defaultTheme, + }; + + state: ChartProviderState = { + dataRegistry: {}, + margin: { top: 30, right: 30, bottom: 30, left: 30 }, + xScale: null, + yScale: null, + colorScale: null, + width: null, + height: null, + combinedData: [], + }; + + componentDidUpdate(prevProps: ChartProviderProps) { + if ( + // @TODO better solution + JSON.stringify(this.props.xScale) !== JSON.stringify(prevProps.xScale) || + JSON.stringify(this.props.yScale) !== JSON.stringify(prevProps.yScale) || + JSON.stringify(this.props?.theme?.colors) !== JSON.stringify(prevProps?.theme?.colors) + ) { + this.updateScales(); + } + } + + /** Adds data to the registry and to combined data if it supports events. */ + registerData: RegisterData = dataToRegister => { + this.setState(state => { + const nextState = { + ...state, + dataRegistry: { + ...state.dataRegistry, + ...Object.values(dataToRegister).reduce( + (combined, curr) => ({ + ...combined, + [curr.key]: { + ...curr, + mouseEvents: curr.mouseEvents !== false, + }, + }), + {}, + ), + }, + combinedData: [ + ...state.combinedData, + ...Object.values(dataToRegister).reduce( + (combined, curr) => [ + ...combined, + ...curr.data.map((datum, index) => ({ + key: curr.key, + datum, + index, + })), + ], + [], + ), + ], + }; + + // it's important that the registry and scales are kept in sync so that + // consumers don't used mismatched data + scales + return { + ...nextState, + ...this.getScales(nextState), + }; + }); + }; + + /** Removes data from the registry and combined data. */ + unregisterData = (keyOrKeys: string | string[]) => { + const keys = new Set(typeof keyOrKeys === 'string' ? [keyOrKeys] : keyOrKeys); + this.setState(state => { + const dataRegistry = Object.entries(state.dataRegistry).reduce((accum, [key, value]) => { + if (!keys.has(key)) accum[key] = value; + return accum; + }, {}); + + const nextState = { + ...state, + dataRegistry, + combinedData: state.combinedData.filter(d => !keys.has(d.key)), + }; + + return { + ...nextState, + ...this.getScales(nextState), + }; + }); + }; + + /** Sets chart dimensions. */ + setChartDimensions: ChartContextType['setChartDimensions'] = ({ width, height, margin }) => { + if (width > 0 && height > 0) { + this.setState({ width, height, margin }, this.updateScales); + } + }; + + getScales = ({ + combinedData, + dataRegistry, + margin, + width, + height, + }: ChartProviderState) => { + const { + theme, + xScale: xScaleConfig, + yScale: yScaleConfig, + colorScale: colorScaleConfig, + } = this.props; + + if (width == null || height == null) return; + + let xScale = createScale({ + data: combinedData.map(({ key, datum }) => + dataRegistry[key]?.xAccessor(datum), + ) as XScaleInput[], + scaleConfig: xScaleConfig, + range: [margin.left, width - margin.right], + }) as ScaleType; + + let yScale = createScale({ + data: combinedData.map(({ key, datum }) => + dataRegistry[key]?.yAccessor(datum), + ) as YScaleInput[], + scaleConfig: yScaleConfig, + range: [height - margin.bottom, margin.top], + }) as ScaleType; + + const colorScale = scaleOrdinal({ + domain: Object.keys(dataRegistry), + range: theme.colors, + ...colorScaleConfig, + }); + + // apply any updates to the scales from the registry + // @TODO this order currently overrides any changes from x/yScaleConfig + Object.values(dataRegistry).forEach(registry => { + if (registry.xScale) xScale = registry.xScale(xScale); + if (registry.yScale) yScale = registry.yScale(yScale); + }); + + return { xScale, yScale, colorScale }; + }; + + updateScales = () => { + const { width, height } = this.state; + + if (width != null && height != null) { + this.setState(state => this.getScales(state)); + } + }; + + /** */ + findNearestData = (event: React.MouseEvent | React.TouchEvent) => { + const { width, height, margin, xScale, yScale, dataRegistry } = this.state; + + // for each series find the datums with closest x and y + const closestData = {}; + let closestDatum: DatumWithKey | null = null; + let minDistance: number = Number.POSITIVE_INFINITY; + const { x: svgMouseX, y: svgMouseY } = localPoint(event) || {}; + + if (xScale && yScale && svgMouseX != null && svgMouseY != null) { + Object.values(dataRegistry).forEach( + ({ + key, + data, + xAccessor, + yAccessor, + mouseEvents, + findNearestDatum = findNearestDatumXY, + }) => { + // series has mouse events disabled + if (!mouseEvents) return; + + const nearestDatum = findNearestDatum({ + event, + svgMouseX, + svgMouseY, + xScale, + yScale, + xAccessor, + yAccessor, + data, + width, + height, + margin, + key, + }); + + if (nearestDatum) { + const { datum, index, distanceX, distanceY } = nearestDatum; + const distance = Math.sqrt(distanceX ** 2 + distanceY ** 2); + closestData[key] = { key, datum, index }; + closestDatum = distance < minDistance ? closestData[key] : closestDatum; + minDistance = Math.min(distance, minDistance); + } + }, + ); + } + + return { closestData, closestDatum, svgMouseX, svgMouseY }; + }; + + render() { + const { theme } = this.props; + const { width, height, margin, xScale, yScale, colorScale, dataRegistry } = this.state; + return ( + + {this.props.children} + + ); + } +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/providers/TooltipProvider.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/providers/TooltipProvider.tsx new file mode 100644 index 000000000..0fe12522c --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/providers/TooltipProvider.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { useTooltip } from '@vx/tooltip'; +import TooltipContext from '../../context/TooltipContext'; +import { TooltipData } from '../../types'; + +export type EventProviderProps = { + children: React.ReactNode; +}; + +/** Simple wrapper around useTooltip, to provide tooltip data via context. */ +export default function EventProvider({ children }: EventProviderProps) { + const tooltip = useTooltip(); + return {children}; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/AnimatedBars.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/AnimatedBars.tsx new file mode 100644 index 000000000..6fc9f032f --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/AnimatedBars.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { animated, useSprings } from 'react-spring'; + +type Bar = { x: number; y: number; width: number; height: number; color?: string }; +type DimensionAccessor = (bar: Bar) => number; + +export type AnimatedBarsProps = { + bars: Bar[]; + x?: DimensionAccessor; + y?: DimensionAccessor; + width?: DimensionAccessor; + height?: DimensionAccessor; +} & Omit, 'x' | 'y' | 'width' | 'height' | 'ref'>; + +export default function AnimatedBars({ + bars, + x, + y, + width, + height, + ...rectProps +}: AnimatedBarsProps) { + const animatedBars = useSprings( + bars.length, + bars.map(bar => ({ + x: x?.(bar) ?? bar.x, + y: y?.(bar) ?? bar.y, + width: width?.(bar) ?? bar.width, + height: height?.(bar) ?? bar.height, + color: bar.color, + })), + ) as { x: number; y: number; width: number; height: number; color: string }[]; + + return ( + // react complains when using component if we don't wrap in Fragment + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + {animatedBars.map((bar, index) => ( + + ))} + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/BarSeries.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/BarSeries.tsx new file mode 100644 index 000000000..f7f249219 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/BarSeries.tsx @@ -0,0 +1,109 @@ +import React, { useContext, useCallback, useMemo } from 'react'; +import ChartContext from '../../context/ChartContext'; +import { ChartContext as ChartContextType, SeriesProps } from '../../types'; +import withRegisteredData from '../../enhancers/withRegisteredData'; +import isValidNumber from '../../typeguards/isValidNumber'; +import useRegisteredData from '../../hooks/useRegisteredData'; +import findNearestDatumX from '../../util/findNearestDatumX'; +import findNearestDatumY from '../../util/findNearestDatumY'; +import AnimatedBars from './AnimatedBars'; + +type BarSeriesProps = SeriesProps< + Datum, + XScaleInput, + YScaleInput +> & { + /** Whether bars should be rendered horizontally instead of vertically. */ + horizontal?: boolean; + /** Specify bar thickness, useful when not using a 'band' scale. Defaults to `scale.bandwidth()` if available, else `available size / data.length` */ + barThickness?: number; +} & Omit, 'x' | 'y' | 'width' | 'height' | 'ref'>; + +function BarSeries({ + dataKey, + data: _, + xAccessor: __, + yAccessor: ___, + mouseEvents, + horizontal, + barThickness: barThicknessProp, + ...barProps +}: BarSeriesProps) { + const { theme, colorScale, xScale, yScale } = useContext(ChartContext) as ChartContextType< + Datum, + XScaleInput, + YScaleInput + >; + const { data, xAccessor, yAccessor } = useRegisteredData( + dataKey, + ); + const getScaledX = useCallback((d: Datum) => xScale(xAccessor(d)), [xScale, xAccessor]); + const getScaledY = useCallback((d: Datum) => yScale(yAccessor(d)), [yScale, yAccessor]); + + const [xMin, xMax] = xScale.range() as number[]; + const [yMax, yMin] = yScale.range() as number[]; + const innerWidth = Math.abs(xMax - xMin); + const innerHeight = Math.abs(yMax - yMin); + const barThickness: number = + barThicknessProp || + (horizontal + ? // non-bandwidth estimate assumes no missing data values + yScale.bandwidth?.() ?? innerHeight / data.length + : xScale.bandwidth?.() ?? innerWidth / data.length); + + // try to figure out the 0 baseline for correct rendering of negative values + // we aren't sure if these are numeric scales or not a priori + // @ts-ignore + const maybeXZero = xScale(0); + // @ts-ignore + const maybeYZero = yScale(0); + + const xZeroPosition = isValidNumber(maybeXZero) + ? // if maybeXZero _is_ a number, but the scale is not clamped and it's outside the domain + // fallback to the scale's minimum + (Math.max(maybeXZero, Math.min(xMin, xMax)) as number) + : Math.min(xMin, xMax); + const yZeroPosition = isValidNumber(maybeYZero) + ? (Math.min(maybeYZero, Math.max(yMin, yMax)) as number) + : Math.max(yMin, yMax); + + const barColor = colorScale(dataKey) as string; + + const bars = useMemo( + () => + data.map(datum => { + const x = getScaledX(datum); + const y = getScaledY(datum); + const barLength = horizontal ? x - xZeroPosition : y - yZeroPosition; + + return { + x: horizontal ? xZeroPosition + Math.min(0, barLength) : x, + y: horizontal ? y : yZeroPosition + Math.min(0, barLength), + width: horizontal ? Math.abs(barLength) : barThickness, + height: horizontal ? barThickness : Math.abs(barLength), + color: barColor, + }; + }), + [ + horizontal, + barColor, + barThickness, + data, + xZeroPosition, + yZeroPosition, + getScaledX, + getScaledY, + ], + ); + + return ( + + + + ); +} + +export default withRegisteredData(BarSeries, { + legendShape: () => 'rect', + findNearestDatum: ({ horizontal }) => (horizontal ? findNearestDatumY : findNearestDatumX), +}); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/Group.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/Group.tsx new file mode 100644 index 000000000..9d851b366 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/Group.tsx @@ -0,0 +1,197 @@ +import React, { useContext, useMemo, useEffect } from 'react'; +import BarGroup from '@vx/shape/lib/shapes/BarGroup'; +import BarGroupHorizontal from '@vx/shape/lib/shapes/BarGroupHorizontal'; +import { Group as VxGroup } from '@vx/group'; +import { scaleBand } from '@vx/scale'; +import ChartContext from '../../context/ChartContext'; +import { DataRegistry, ChartContext as ChartContextType, NearestDatumArgs } from '../../types'; + +import BarSeries from './BarSeries'; +import findNearestDatumX from '../../util/findNearestDatumX'; +import findNearestDatumY from '../../util/findNearestDatumY'; +import AnimatedBars from './AnimatedBars'; + +const GROUP_ACCESSOR = d => d.group; + +export type GroupProps = { + horizontal?: boolean; + children: typeof BarSeries; +} & Omit, 'x' | 'y' | 'width' | 'height' | 'ref'>; + +// @TODO add GroupKeys type +export default function Group({ + horizontal, + children, + ...rectProps +}: GroupProps) { + const { + width, + height, + margin, + xScale, + yScale, + colorScale, + dataRegistry, + registerData, + unregisterData, + } = useContext(ChartContext) as ChartContextType; + + // extract data keys from child series + const dataKeys: string[] = useMemo( + () => React.Children.map(children, child => child.props.dataKey), + [children], + ); + + // + const groupScale = useMemo( + () => + scaleBand({ + domain: [...dataKeys], + range: [0, (horizontal ? yScale : xScale)?.bandwidth?.()], + padding: 0.1, + }), + [dataKeys, xScale, yScale, horizontal], + ); + + // @todo, this should be refactored such that it can be memoized. + // currently it references groupScale which depends on xScale, yScale, + // and thus causes an infinite loop for updating the data registry. + const findNearestDatum = (args: NearestDatumArgs) => { + const nearestDatum = horizontal + ? findNearestDatumY(args) + : findNearestDatumX(args); + + if (!nearestDatum) return null; + + const distanceX = horizontal + ? nearestDatum.distanceX + : Math.abs( + args.svgMouseX - + (args.xScale(args.xAccessor(nearestDatum.datum)) + + groupScale(args.key) + + groupScale.bandwidth() / 2), + ); + + const distanceY = horizontal + ? Math.abs( + args.svgMouseY - + (args.yScale(args.yAccessor(nearestDatum.datum)) + + groupScale(args.key) + + groupScale.bandwidth() / 2), + ) + : nearestDatum.distanceY; + + return { + ...nearestDatum, + distanceX, + distanceY, + }; + }; + + useEffect( + // register all child data + () => { + const dataToRegister: DataRegistry = {}; + + React.Children.map(children, child => { + const { dataKey: key, data, xAccessor, yAccessor, mouseEvents } = child.props; + dataToRegister[key] = { key, data, xAccessor, yAccessor, mouseEvents, findNearestDatum }; + }); + + registerData(dataToRegister); + return () => unregisterData(Object.keys(dataToRegister)); + }, + // @TODO fix findNearestDatum + // can't include findNearestDatum as it depends on groupScale which depends + // on the registry so will cause an infinite loop. + [registerData, unregisterData, children], + ); + + // merge all child data by x value + const combinedData: { + [dataKey in string | 'group']: YScaleInput | XScaleInput; + }[] = useMemo(() => { + const dataByGroupValue = {}; + dataKeys.forEach(key => { + const { data = [], xAccessor, yAccessor } = dataRegistry[key] || {}; + + // this should exist but double check + if (!xAccessor || !yAccessor) return; + + data.forEach(d => { + const group = (horizontal ? yAccessor : xAccessor)(d); + const groupKey = String(group); + if (!dataByGroupValue[groupKey]) dataByGroupValue[groupKey] = { group }; + dataByGroupValue[groupKey][key] = (horizontal ? xAccessor : yAccessor)(d); + }); + }); + return Object.values(dataByGroupValue); + }, [horizontal, dataKeys, dataRegistry]); + + // if scales and data are not available in the registry, bail + if (dataKeys.some(key => dataRegistry[key] == null) || !xScale || !yScale || !colorScale) { + return null; + } + + // @TODO handle NaNs from non-number inputs, prob fallback to 0 + // @TODO should consider refactoring base shapes to handle negative values better + const scaledZeroPosition = (horizontal ? xScale : yScale)(0); + + return horizontal ? ( + + data={combinedData} + keys={dataKeys} + width={width - margin.left - margin.right} // this is unused, should be removed in component + x={xValue => xScale(xValue)} + y0={GROUP_ACCESSOR} + y0Scale={yScale} // group position + y1Scale={groupScale} + xScale={xScale} + color={colorScale} + > + {barGroups => + barGroups.map(barGroup => ( + // @TODO if we use we might be able to make this animate on first render + + Math.min(scaledZeroPosition, bar.x)} + y={bar => bar.y} + width={bar => Math.abs(bar.width - scaledZeroPosition)} + height={bar => bar.height} + rx={2} + {...rectProps} + /> + + )) + } + + ) : ( + + data={combinedData} + keys={dataKeys} + height={height - margin.top - margin.bottom} // BarGroup should figure this out from yScale + x0={GROUP_ACCESSOR} + x0Scale={xScale} // group position + x1Scale={groupScale} + yScale={yScale} + color={dataKey => colorScale(dataKey) as string} + > + {barGroups => + barGroups.map(barGroup => ( + + bar.x} + y={bar => Math.min(scaledZeroPosition, bar.y)} + width={bar => bar.width} + height={bar => Math.abs(scaledZeroPosition - bar.y)} + rx={2} + {...rectProps} + /> + + )) + } + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/LineSeries.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/LineSeries.tsx new file mode 100644 index 000000000..7c4bbea38 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/LineSeries.tsx @@ -0,0 +1,72 @@ +import React, { useContext, useCallback } from 'react'; +import { animated, useSpring } from 'react-spring'; +import LinePath, { LinePathProps } from '@vx/shape/lib/shapes/LinePath'; +import ChartContext from '../../context/ChartContext'; +import { SeriesProps } from '../../types'; +import withRegisteredData from '../../enhancers/withRegisteredData'; +import isValidNumber from '../../typeguards/isValidNumber'; +import useRegisteredData from '../../hooks/useRegisteredData'; + +type LineSeriesProps = SeriesProps< + Datum, + XScaleInput, + YScaleInput +> & + Omit, 'x' | 'y' | 'data' | 'innerRef'> & + Omit, keyof LinePathProps>; + +function LineSeries({ + data: _, + xAccessor: __, + yAccessor: ___, + dataKey, + mouseEvents, + ...lineProps +}: LineSeriesProps) { + const { xScale, yScale, colorScale } = useContext(ChartContext); + const { data, xAccessor, yAccessor } = + useRegisteredData(dataKey) || {}; + + const getScaledX = useCallback( + (d: Datum) => { + const x = xScale(xAccessor?.(d)); + return isValidNumber(x) ? x + (xScale.bandwidth?.() ?? 0) / 2 : null; + }, + [xScale, xAccessor], + ); + + const getScaledY = useCallback( + (d: Datum) => { + const y = yScale(yAccessor?.(d)); + return isValidNumber(y) ? y + (yScale.bandwidth?.() ?? 0) / 2 : null; + }, + [yScale, yAccessor], + ); + + if (!data || !xAccessor || !yAccessor) return null; + + const color = colorScale(dataKey) ?? '#222'; + + return ( + + data={data} x={getScaledX} y={getScaledY} {...lineProps}> + {({ path }) => } + + + ); +} + +/** Separate component so that we don't use the `useSpring` hook in a render function callback. */ +function AnimatedPath({ + d, + ...lineProps +}: { d: string } & Partial, 'ref'>>) { + const tweenedPath = useSpring({ d, config: { precision: 0.01 } }); + return ; +} + +export default React.memo( + withRegisteredData(LineSeries, { + legendShape: ({ strokeDasharray }) => (strokeDasharray ? 'dashed-line' : 'line'), + }), +); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/Stack.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/Stack.tsx new file mode 100644 index 000000000..80dc622e5 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/components/series/Stack.tsx @@ -0,0 +1,225 @@ +import React, { useContext, useMemo, useEffect, useRef, useCallback } from 'react'; +import { extent } from 'd3-array'; +import BarStack from '@vx/shape/lib/shapes/BarStack'; +import BarStackHorizontal from '@vx/shape/lib/shapes/BarStackHorizontal'; +import { BarStack as BarStackType } from '@vx/shape/lib/types'; +import ChartContext from '../../context/ChartContext'; +import { + DataRegistry, + ChartContext as ChartContextType, + NearestDatumArgs, + ScaleType, +} from '../../types'; + +import BarSeries from './BarSeries'; +import findNearestDatumY from '../../util/findNearestDatumY'; +import findNearestDatumX from '../../util/findNearestDatumX'; +import AnimatedBars from './AnimatedBars'; + +const STACK_ACCESSOR = d => d.stack; + +type CombinedData = { + [dataKey: string]: XScaleInput | YScaleInput | number; +} & { stack: XScaleInput | YScaleInput; positiveSum: number; negativeSum: number }; + +export type GroupProps = { + horizontal?: boolean; + children: typeof BarSeries; +} & Omit, 'x' | 'y' | 'width' | 'height' | 'ref'>; + +export default function Stack({ + horizontal, + children, + ...rectProps +}: GroupProps) { + const { xScale, yScale, colorScale, dataRegistry, registerData, unregisterData, height, margin } = + (useContext(ChartContext) as ChartContextType) || {}; + + // extract data keys from child series + const dataKeys: string[] = useMemo( + () => React.Children.map(children, child => child.props.dataKey), + [children], + ); + + // use a ref to the stacks for mouse movements + const stacks = useRef[] | null>(null); + + // override the findNearestDatum logic + const findNearestDatum = useCallback( + (args: NearestDatumArgs) => { + if (!stacks.current) return null; + + const nearestDatum = horizontal + ? findNearestDatumY(args) + : findNearestDatumX(args); + + if (!nearestDatum) return null; + + // find the stack for this key, and the bar in that stack corresponding to nearestDatum + const stack = stacks.current.find(currStack => currStack.key === args.key); + const bar = stack?.bars?.[nearestDatum.index]; + + if (!bar) return null; + + const distanceX = horizontal + ? // if svgMouseX is *on* the bar, set distance to 0 + args.svgMouseX >= bar.x && args.svgMouseX <= bar.x + bar.width + ? 0 + : // otherwise take the min distance between the left and the right of the bar + Math.min( + Math.abs(args.svgMouseX - bar.x), + Math.abs(args.svgMouseX - (bar.x + bar.width)), + ) + : nearestDatum.distanceX; + + const distanceY = horizontal + ? nearestDatum.distanceY + : // if svgMouseY is *on* the bar, set distance to 0 + args.svgMouseY >= bar.y && args.svgMouseY <= bar.y + bar.height + ? 0 + : // otherwise take the min distance between the top and the bottom of the bar + Math.min( + Math.abs(args.svgMouseY - bar.y), + Math.abs(args.svgMouseY - (bar.y + bar.height)), + ); + + return { + ...nearestDatum, + distanceX, + distanceY, + }; + }, + [horizontal], + ); + + // group all child data by stack value, this format is needed by BarStack + const combinedData: CombinedData[] = useMemo(() => { + const dataByStackValue: { + [stackValue: string]: CombinedData; + } = {}; + React.Children.forEach(children, child => { + const { dataKey, data = [], xAccessor, yAccessor } = child.props; + + // this should exist but double check + if (!xAccessor || !yAccessor) return; + + data.forEach(d => { + const stack = (horizontal ? yAccessor : xAccessor)(d); + const stackKey = String(stack); + if (!dataByStackValue[stackKey]) { + dataByStackValue[stackKey] = { stack, positiveSum: 0, negativeSum: 0 }; + } + const value = (horizontal ? xAccessor : yAccessor)(d); + dataByStackValue[stackKey][dataKey] = value; + dataByStackValue[stackKey][value >= 0 ? 'positiveSum' : 'negativeSum'] += value; + }); + }); + + return Object.values(dataByStackValue); + }, [horizontal, children]); + + // update the domain to account for the (directional) stacked value + const comprehensiveDomain: number[] = useMemo( + () => + extent( + combinedData.map(d => d.positiveSum).concat(combinedData.map(d => d.negativeSum)), + d => d, + ).filter(val => val != null), + [combinedData], + ); + + // register all child data + useEffect(() => { + const dataToRegister: DataRegistry = {}; + + React.Children.map(children, child => { + const { dataKey: key, data, xAccessor, yAccessor, mouseEvents } = child.props; + dataToRegister[key] = { key, data, xAccessor, yAccessor, mouseEvents, findNearestDatum }; + + // only need to update the domain for one of the keys + if (comprehensiveDomain.length > 0 && dataKeys.indexOf(key) === 0) { + if (horizontal) { + dataToRegister[key].xScale = (scale: ScaleType) => + scale.domain( + extent([...scale.domain(), ...comprehensiveDomain], d => d), + ); + } else { + dataToRegister[key].yScale = (scale: ScaleType) => + scale.domain( + extent([...scale.domain(), ...comprehensiveDomain], d => d), + ); + } + } + }); + + registerData(dataToRegister); + + // unregister data on unmount + return () => unregisterData(Object.keys(dataToRegister)); + }, [ + horizontal, + comprehensiveDomain, + registerData, + unregisterData, + children, + findNearestDatum, + dataKeys, + ]); + + // if scales and data are not available in the registry, bail + if (dataKeys.some(key => dataRegistry[key] == null) || !xScale || !yScale || !colorScale) { + return null; + } + + const hasSomeNegativeValues = comprehensiveDomain.some(num => num < 0); + + return horizontal ? ( + + {barStacks => { + // use this reference to find nearest mouse values + stacks.current = barStacks; + return barStacks.map((barStack, index) => ( + + )); + }} + + ) : ( + // @TODO types + + {barStacks => { + // use this reference to find nearest mouse values + stacks.current = barStacks; + return barStacks.map((barStack, index) => ( + + )); + }} + + ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/context/ChartContext.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/context/ChartContext.tsx new file mode 100644 index 000000000..032949855 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/context/ChartContext.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { ChartContext as ChartContextType } from '../types'; + +const ChartContext = React.createContext(null); + +export default ChartContext; diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/context/TooltipContext.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/context/TooltipContext.tsx new file mode 100644 index 000000000..3056a55df --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/context/TooltipContext.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { TooltipContext as TooltipContextType } from '../types'; + +const TooltipContext = React.createContext(null); + +export default TooltipContext; diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/createScale.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/createScale.ts new file mode 100644 index 000000000..912ab1d89 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/createScale.ts @@ -0,0 +1,62 @@ +import { scaleLinear, scaleTime, scaleUtc, scaleBand, scaleOrdinal } from '@vx/scale'; +import { extent } from 'd3-array'; +import { ScaleConfig, StringLike, NumberLike, ScaleOutput } from './types'; + +interface CreateScaleConfig { + data: ScaleInput[]; + range: [number, number]; + scaleConfig: ScaleConfig; +} + +export default function createScale({ + data, + range: defaultRange, + scaleConfig, +}: CreateScaleConfig) { + const { includeZero, type: scaleType, ...restConfig } = scaleConfig; + + // use blocks so types are happy + if (scaleType === 'band') { + const range = (restConfig.range as [ScaleOutput, ScaleOutput]) || defaultRange; + return scaleBand({ + domain: data, + ...restConfig, + range, + }); + } + if (scaleType === 'ordinal') { + const range = (restConfig.range as [ScaleOutput, ScaleOutput]) || defaultRange; + return scaleOrdinal({ + domain: data, + ...restConfig, + range, + }); + } + if (scaleType === 'linear') { + const [min, max] = extent((data as unknown[]) as number[], d => d); + const domain: number[] = ((restConfig.domain as unknown[]) as number[]) || [ + scaleType === 'linear' && includeZero ? Math.min(0, min) : min, + scaleType === 'linear' && includeZero ? Math.max(0, max) : max, + ]; + const range = (restConfig.range as ScaleOutput[]) || defaultRange; + return scaleLinear({ + ...restConfig, + domain, + range, + }); + } + if (scaleType === 'time' || scaleType === 'timeUtc') { + const range = (restConfig.range as ScaleOutput[]) || defaultRange; + const domain = + ((restConfig.domain as unknown[]) as NumberLike[]) || + extent((data as unknown[]) as NumberLike[], d => d); + + return (scaleType === 'time' ? scaleTime : scaleUtc)({ + ...restConfig, + domain, + range, + }); + } + + return scaleLinear({}); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/enhancers/withDefinedContextScales.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/enhancers/withDefinedContextScales.tsx new file mode 100644 index 000000000..5dde71e45 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/enhancers/withDefinedContextScales.tsx @@ -0,0 +1,18 @@ +import React, { useContext } from 'react'; +import ChartContext from '../context/ChartContext'; + +/** + * An HOC that renders the `BaseComponent` only if x and y scales are available in context. + * This is useful for avoiding nasty syntax with undefined scales when using hooks. + */ +export default function withDefinedContextScales( + BaseComponent: React.ComponentType, +) { + const WrappedSeriesComponent: React.FunctionComponent = props => { + const { xScale, yScale } = useContext(ChartContext); + + return xScale && yScale ? : null; + }; + + return WrappedSeriesComponent; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/enhancers/withRegisteredData.tsx b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/enhancers/withRegisteredData.tsx new file mode 100644 index 000000000..9011f29c6 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/enhancers/withRegisteredData.tsx @@ -0,0 +1,48 @@ +import React, { FunctionComponent, useContext } from 'react'; +import ChartContext from '../context/ChartContext'; +import { SeriesProps, LegendShape, FindNearestDatum } from '../types'; +import useDataRegistry from '../hooks/useDataRegistry'; + +/** + * An HOC that handles registering the Series's data and renders the + * `BaseSeriesComponent` only if x and y scales are available in context. This is + * useful for avoiding nasty syntax with undefined scales when using hooks. + */ +export default function withRegisteredData< + Datum, + XScaleInput, + YScaleInput, + BaseComponentProps extends SeriesProps +>( + BaseSeriesComponent: React.ComponentType, + { + findNearestDatum, + legendShape, + }: { + findNearestDatum?: ( + props: BaseComponentProps, + ) => FindNearestDatum; + legendShape?: (props: BaseComponentProps) => LegendShape; + }, +) { + const WrappedSeriesComponent: FunctionComponent = props => { + const { dataKey, data, xAccessor, yAccessor, mouseEvents } = props; + const { xScale, yScale, dataRegistry } = useContext(ChartContext); + + useDataRegistry({ + key: dataKey, + data, + xAccessor, + yAccessor, + mouseEvents, + legendShape: legendShape?.(props), + findNearestDatum: findNearestDatum?.(props), + }); + + return xScale && yScale && dataRegistry?.[dataKey]?.data === data ? ( + + ) : null; + }; + + return WrappedSeriesComponent; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/hooks/useDataRegistry.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/hooks/useDataRegistry.ts new file mode 100644 index 000000000..10ac7f088 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/hooks/useDataRegistry.ts @@ -0,0 +1,33 @@ +import { useEffect, useContext } from 'react'; +import ChartContext from '../context/ChartContext'; +import { DataRegistry } from '../types'; + +export default function useDataRegistry({ + data, + key, + xAccessor, + yAccessor, + mouseEvents, + legendShape, + findNearestDatum, +}: DataRegistry[string]) { + const { registerData, unregisterData } = useContext(ChartContext); + + // register data on mount + useEffect(() => { + registerData({ + [key]: { key, data, xAccessor, yAccessor, mouseEvents, legendShape, findNearestDatum }, + }); + return () => unregisterData(key); + }, [ + registerData, + unregisterData, + key, + data, + xAccessor, + yAccessor, + mouseEvents, + legendShape, + findNearestDatum, + ]); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/hooks/useRegisteredData.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/hooks/useRegisteredData.ts new file mode 100644 index 000000000..70c5c92ce --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/hooks/useRegisteredData.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import ChartContext from '../context/ChartContext'; +import { DataRegistry } from '../types'; + +export default function useRegisteredData< + Datum = unknown, + XScaleInput = unknown, + YScaleInput = unknown +>(dataKey: string): DataRegistry[string] | null { + const { dataRegistry } = useContext(ChartContext); + + return dataRegistry[dataKey] || null; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/buildChartTheme.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/buildChartTheme.ts new file mode 100644 index 000000000..20a687907 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/buildChartTheme.ts @@ -0,0 +1,142 @@ +import { TextStyles, XYChartTheme, LineStyles } from '../types/theme'; + +export type ThemeConfig = { + baseColor: string; + colors: string[]; + + // labels + labelColor: string; + labelStyles?: TextStyles; + tickLabelColor: string; + tickLabelStyles?: TextStyles; + + // lines + xAxisLineStyles?: LineStyles; + yAxisLineStyles?: LineStyles; + xTickLineStyles?: LineStyles; + yTickLineStyles?: LineStyles; + tickLength: number; + + gridColor: string; + gridColorDark: string; + gridStyles?: LineStyles; + + font: { + weights: { light: number | string; bold: number | string }; + small: TextStyles; + regular: TextStyles; + }; +}; + +/** Simplified API to build a full XYChartTheme. */ +export default function buildChartTheme(theme: ThemeConfig): XYChartTheme { + const baseLabel: TextStyles = { + ...theme.font.regular, + fill: theme.labelColor, + textAnchor: 'middle', + fontWeight: theme.font.weights.bold, + pointerEvents: 'none', + ...theme.labelStyles, + } as const; + + const baseTickLabel: TextStyles = { + ...theme.font.small, + fill: theme.tickLabelColor, + textAnchor: 'middle', + fontWeight: theme.font.weights.light, + pointerEvents: 'none', + ...theme.tickLabelStyles, + } as const; + + const tickLabels: { top: TextStyles; right: TextStyles; bottom: TextStyles; left: TextStyles } = { + top: { + ...baseTickLabel, + dy: '-0.25em', // needs to include font-size + }, + bottom: { + ...baseTickLabel, + dy: '0.125em', + }, + left: { + ...baseTickLabel, + textAnchor: 'end', + dx: '-0.25em', + dy: '0.25em', + }, + right: { + ...baseTickLabel, + textAnchor: 'start', + dx: '0.25em', + dy: '0.25em', + }, + }; + + return { + baseColor: theme.baseColor, + colors: [...theme.colors], + labelStyles: { + ...baseLabel, + }, + gridStyles: { + stroke: theme.gridColor, + strokeWidth: 1, + ...theme.gridStyles, + }, + xAxisStyles: { + stroke: theme.gridColorDark, + strokeWidth: 2, + ...theme.xAxisLineStyles, + label: { + bottom: { + ...baseLabel, + dy: '-0.25em', + }, + top: { + ...baseLabel, + dy: '-0.25em', + }, + }, + }, + yAxisStyles: { + stroke: theme.gridColor, + strokeWidth: 1, + ...theme.yAxisLineStyles, + label: { + left: { + ...baseLabel, + dx: '-1.5em', + }, + right: { + ...baseLabel, + dx: '1.5em', + }, + }, + }, + xTickStyles: { + stroke: theme.gridColor, + tickLength: theme.tickLength, + ...theme.xTickLineStyles, + label: { + bottom: { + ...tickLabels.bottom, + }, + top: { + ...tickLabels.top, + }, + }, + }, + yTickStyles: { + stroke: theme.gridColor, + tickLength: theme.tickLength, + ...theme.yTickLineStyles, + label: { + left: { + ...tickLabels.left, + }, + right: { + ...tickLabels.right, + }, + }, + }, + }; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/colors.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/colors.ts new file mode 100644 index 000000000..b3539e801 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/colors.ts @@ -0,0 +1,173 @@ +// source https://yeun.github.io/open-color/ +export const allColors: { + [hue: string]: [string, string, string, string, string, string, string, string, string, string]; +} = { + red: [ + '#fff5f5', + '#ffe3e3', + '#ffc9c9', + '#ffa8a8', + '#ff8787', + '#ff6b6b', + '#fa5252', + '#f03e3e', + '#e03131', + '#c92a2a', + ], + pink: [ + '#fff0f6', + '#ffdeeb', + '#fcc2d7', + '#faa2c1', + '#f783ac', + '#f06595', + '#e64980', + '#d6336c', + '#c2255c', + '#a61e4d', + ], + grape: [ + '#f8f0fc', + '#f3d9fa', + '#eebefa', + '#e599f7', + '#da77f2', + '#cc5de8', + '#be4bdb', + '#ae3ec9', + '#9c36b5', + '#862e9c', + ], + violet: [ + '#f3f0ff', + '#e5dbff', + '#d0bfff', + '#b197fc', + '#9775fa', + '#845ef7', + '#7950f2', + '#7048e8', + '#6741d9', + '#5f3dc4', + ], + indigo: [ + '#edf2ff', + '#dbe4ff', + '#bac8ff', + '#91a7ff', + '#748ffc', + '#5c7cfa', + '#4c6ef5', + '#4263eb', + '#3b5bdb', + '#364fc7', + ], + blue: [ + '#e8f7ff', + '#ccedff', + '#a3daff', + '#72c3fc', + '#4dadf7', + '#329af0', + '#228ae6', + '#1c7cd6', + '#1b6ec2', + '#1862ab', + ], + cyan: [ + '#e3fafc', + '#c5f6fa', + '#99e9f2', + '#66d9e8', + '#3bc9db', + '#22b8cf', + '#15aabf', + '#1098ad', + '#0c8599', + '#0b7285', + ], + teal: [ + '#e6fcf5', + '#c3fae8', + '#96f2d7', + '#63e6be', + '#38d9a9', + '#20c997', + '#12b886', + '#0ca678', + '#099268', + '#087f5b', + ], + green: [ + '#ebfbee', + '#d3f9d8', + '#b2f2bb', + '#8ce99a', + '#69db7c', + '#51cf66', + '#40c057', + '#37b24d', + '#2f9e44', + '#2b8a3e', + ], + lime: [ + '#f4fce3', + '#e9fac8', + '#d8f5a2', + '#c0eb75', + '#a9e34b', + '#94d82d', + '#82c91e', + '#74b816', + '#66a80f', + '#5c940d', + ], + yellow: [ + '#fff9db', + '#fff3bf', + '#ffec99', + '#ffe066', + '#ffd43b', + '#fcc419', + '#fab005', + '#f59f00', + '#f08c00', + '#e67700', + ], + orange: [ + '#fff4e6', + '#ffe8cc', + '#ffd8a8', + '#ffc078', + '#ffa94d', + '#ff922b', + '#fd7e14', + '#f76707', + '#e8590c', + '#d9480f', + ], + gray: [ + '#f8f9fa', + '#f1f3f5', + '#e9ecef', + '#dee2e6', + '#ced4da', + '#adb5bd', + '#868e96', + '#495057', + '#343a40', + '#212529', + ], +}; + +export const grayColors = allColors.gray; +export const textColor = grayColors[7]; + +export const defaultColors = [ + allColors.cyan[9], + allColors.cyan[6], + allColors.yellow[5], + allColors.red[4], + allColors.violet[8], + allColors.grape[3], +]; diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/darkTheme.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/darkTheme.ts new file mode 100644 index 000000000..852673054 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/darkTheme.ts @@ -0,0 +1,14 @@ +import { defaultColors, grayColors } from './colors'; +import svgFont from './default/svgFont'; +import buildChartTheme from './buildChartTheme'; + +export default buildChartTheme({ + baseColor: '#222', + colors: defaultColors, + font: svgFont, + tickLength: 4, + labelColor: grayColors[0], + tickLabelColor: grayColors[2], + gridColor: grayColors[4], + gridColorDark: grayColors[1], +}); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/default/index.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/default/index.ts new file mode 100644 index 000000000..16fb1b44c --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/default/index.ts @@ -0,0 +1,14 @@ +import { defaultColors, grayColors } from '../colors'; +import svgFont from './svgFont'; +import buildChartTheme from '../buildChartTheme'; + +export default buildChartTheme({ + baseColor: '#fff', + colors: defaultColors, + font: svgFont, + tickLength: 4, + labelColor: grayColors[9], + tickLabelColor: grayColors[7], + gridColor: grayColors[5], + gridColorDark: grayColors[9], +}); diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/default/svgFont.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/default/svgFont.ts new file mode 100644 index 000000000..922900289 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/theme/default/svgFont.ts @@ -0,0 +1,45 @@ +import { textColor } from '../colors'; +import { TextStyles } from '../../types/theme'; + +const getSvgFont = ({ + fontFamily, + fontSize, + letterSpacing, +}: { + fontFamily: string; + fontSize: number; + letterSpacing: number; +}): TextStyles => ({ + fill: textColor, + stroke: 'none', + fontFamily, + fontSize, + letterSpacing, + textAnchor: 'inherit', +}); + +const fontFamily = '-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif'; + +export default { + fontFamily, + + weights: { + light: 200, + bold: 700, + }, + + small: { + ...getSvgFont({ + fontFamily, + fontSize: 10, + letterSpacing: 0.4, + }), + }, + regular: { + ...getSvgFont({ + fontFamily, + fontSize: 12, + letterSpacing: 0.4, + }), + }, +}; diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/typeguards/isValidNumber.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/typeguards/isValidNumber.ts new file mode 100644 index 000000000..e16a69c11 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/typeguards/isValidNumber.ts @@ -0,0 +1,3 @@ +export default function isValidNumber(_: unknown): _ is number { + return _ != null && typeof _ === 'number' && !isNaN(_) && isFinite(_); +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/context.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/context.ts new file mode 100644 index 000000000..a038208ee --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/context.ts @@ -0,0 +1,111 @@ +import { UseTooltipParams } from '@vx/tooltip/lib/hooks/useTooltip'; +import { XYChartTheme } from './theme'; +import { ScaleType, Margin, ScaleOutput, LegendShape } from '.'; + +// ChartContext --------------------------------------------------------------- +export interface DataRegistry { + [key: string]: { + /** unique data key */ + key: string; + /** array of data */ + data: Datum[]; + /** function that returns the x value of a datum. */ + xAccessor: (d: Datum) => XScaleInput; + /** function that returns the y value of a datum. */ + yAccessor: (d: Datum) => YScaleInput; + /** whether the entry supports mouse events. */ + mouseEvents?: boolean; + /** Optionally update the xScale. */ + xScale?: (xScale: ScaleType) => ScaleType; + /** Optionally update the yScale. */ + yScale?: (yScale: ScaleType) => ScaleType; + /** Optionally override logic for finding the nearest data point to a mouse event. */ + findNearestDatum?: FindNearestDatum; + /** Legend shape */ + legendShape?: LegendShape; + }; +} + +export type RegisterData = ( + data: DataRegistry, +) => void; + +export type DatumWithKey = { datum: Datum; key: string; index: number }; + +export type FindNearestDatum = ( + args: NearestDatumArgs, +) => null | { + /** Closest datum. */ + datum: Datum; + /** Index of the closest datum. */ + index: number; + /** X coord distance in px from event to datum. Used to rank overall closest datum. */ + distanceX: number; + /** Y coord distance in px from event to datum. Used to rank overall closest datum. */ + distanceY: number; +}; + +export interface ChartContext< + Datum = unknown, + XScaleInput = unknown, + YScaleInput = unknown, + DataKeys extends string = string +> { + theme: XYChartTheme; + xScale: ScaleType | null; + yScale: ScaleType | null; + colorScale: ScaleType; + width: number | null; + height: number | null; + margin: Margin; + dataRegistry: DataRegistry; + registerData: RegisterData; + unregisterData: (keyOrKeys: string | string[]) => void; + setChartDimensions: (dims: { width: number; height: number; margin: Margin }) => void; + findNearestData: ( + event: React.MouseEvent | React.TouchEvent, + ) => { + svgMouseX: number | null; + svgMouseY: number | null; + closestDatum: DatumWithKey; + closestData: { [dataKey: string]: DatumWithKey }; + }; +} + +export type NearestDatumArgs = { + event: React.MouseEvent | React.TouchEvent; + svgMouseX: number; + svgMouseY: number; + xAccessor: (d: Datum) => XScaleInput; + yAccessor: (d: Datum) => YScaleInput; + data: Datum[]; + key: string; +} & Pick< + ChartContext, + 'xScale' | 'yScale' | 'width' | 'height' | 'margin' +>; + +// TooltipContext --------------------------------------------------------------- + +export interface TooltipData { + /** x coord of event in svg space. */ + svgMouseX: number | null; + /** y coord of event in svg space. */ + svgMouseY: number | null; + /** x coord of event in page space. */ + pageX: number | null; + /** y coord of event in page space. */ + pageY: number | null; + /** x coord of the chart contaainer svg from its boundingClientRect. */ + svgOriginX: number | null; + /** y coord of the chart contaainer svg from its boundingClientRect. */ + svgOriginY: number | null; + /** The closest datum across all `dataKeys`. */ + closestDatum: DatumWithKey; + /** The closest datum for each `dataKey`. */ + closestData: { + [key in DataKeys]: DatumWithKey; + }; +} + +export type TooltipContext = UseTooltipParams; diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/index.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/index.ts new file mode 100644 index 000000000..869d2ba8d --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/index.ts @@ -0,0 +1,38 @@ +import { ScaleType as BaseScaleType } from '@vx/legend/lib/types'; +import { XYChartTheme } from './theme'; + +export * from './context'; + +export type ChartTheme = XYChartTheme; +export type LegendShape = 'rect' | 'line' | 'dashed-line' | 'circle'; +export type StringLike = string | { toString(): string }; +export type NumberLike = number | { valueOf(): number }; +export type ScaleOutput = number; + +export type ScaleConfigType = 'linear' | 'band' | 'ordinal' | 'time' | 'timeUtc'; + +export type ScaleConfig = { + type: ScaleConfigType; + domain?: ScaleInput[]; + range?: number[]; + includeZero?: boolean; + nice?: boolean; + clamp?: boolean; +}; + +export type ScaleType = BaseScaleType; + +export type Margin = { + top: number; + right: number; + bottom: number; + left: number; +}; + +export type SeriesProps = { + dataKey: string; + data: Datum[]; + xAccessor: (d: Datum) => XScaleInput; + yAccessor: (d: Datum) => YScaleInput; + mouseEvents?: boolean; +}; diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/theme.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/theme.ts new file mode 100644 index 000000000..c7b30cd27 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/types/theme.ts @@ -0,0 +1,49 @@ +import React from 'react'; + +type TextPropsToOmit = 'onCopy' | 'event'; + +export type TextStyles = Omit, TextPropsToOmit> & { + textAnchor: 'start' | 'middle' | 'end' | 'inherit'; +}; + +export type LineStyles = Omit, 'Key'>; + +export type GridStyles = LineStyles; + +/** A complete chart theme includes style definitions for all axis orientations. */ +export interface XYChartTheme { + baseColor: string; + colors: string[]; + labelStyles: TextStyles; + gridStyles: GridStyles; + + // axes styles (line + labels) + xAxisStyles: { + label: { + bottom: TextStyles; + top: TextStyles; + }; + } & LineStyles; + yAxisStyles: { + label: { + left: TextStyles; + right: TextStyles; + }; + } & LineStyles; + + // tick styles (line + labels) + xTickStyles: LineStyles & { + tickLength: number; + label: { + bottom: TextStyles; + top: TextStyles; + }; + }; + yTickStyles: LineStyles & { + tickLength: number; + label: { + left: TextStyles; + right: TextStyles; + }; + }; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumSingleDimension.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumSingleDimension.ts new file mode 100644 index 000000000..fdca25bf7 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumSingleDimension.ts @@ -0,0 +1,49 @@ +import { bisector, range as d3Range, bisectLeft } from 'd3-array'; +import { ScaleType } from '../types'; + +export default function findNearestDatumSingleDimension({ + scale, + accessor, + mouseCoord, + data, +}: { + scale: ScaleType; + accessor: (d: Datum) => ScaleInput; + mouseCoord: number; + data: Datum[]; +}) { + const isOrdinalScale = !('invert' in scale) || typeof scale.invert !== 'function'; + + let nearestDatum: Datum; + let nearestDatumIndex: number; + if (isOrdinalScale) { + // Ordinal scales don't have an invert function but they do have discrete domains + // so we manually invert + const domain = scale.domain(); + const range = scale.range(); + const sortedRange = [...range].sort(); // bisectLeft assumes sort + const rangePoints = d3Range(sortedRange[0], sortedRange[1], scale.step()); + const domainIndex = bisectLeft(rangePoints, mouseCoord); + // y-axis scales may have reverse ranges, correct for this + const sortedDomain = range[0] < range[1] ? domain : domain.reverse(); + const domainValue = sortedDomain[domainIndex - 1]; + const index = data.findIndex(d => String(accessor(d)) === String(domainValue)); + nearestDatum = data[index]; + nearestDatumIndex = index; + } else { + const bisect = bisector(accessor).left; + const dataValue = scale.invert(mouseCoord); + const index = bisect(data, dataValue); + const d0 = data[index - 1]; + const d1 = data[index]; + nearestDatum = + !d0 || Math.abs(dataValue - accessor(d0)) > Math.abs(dataValue - accessor(d1)) ? d1 : d0; + nearestDatumIndex = nearestDatum === d0 ? index - 1 : index; + } + + if (!nearestDatum) return null; + + const distance = Math.abs(scale(accessor(nearestDatum)) - mouseCoord); + + return { datum: nearestDatum, index: nearestDatumIndex, distance }; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumX.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumX.ts new file mode 100644 index 000000000..6a62fb41e --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumX.ts @@ -0,0 +1,27 @@ +import findNearestDatumSingleDimension from './findNearestDatumSingleDimension'; +import { NearestDatumArgs } from '../types'; + +export default function findNearestDatumX< + Datum = unknown, + XScaleInput = unknown, + YScaleInput = unknown +>({ + xScale: scale, + xAccessor: accessor, + svgMouseX: mouseCoord, + data, +}: NearestDatumArgs): { + datum: Datum; + index: number; + distanceX: number; + distanceY: number; +} | null { + const { datum, distance, index } = + findNearestDatumSingleDimension({ + scale, + accessor, + mouseCoord, + data, + }) ?? {}; + return datum ? { datum, index, distanceX: distance, distanceY: 0 } : null; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumXY.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumXY.ts new file mode 100644 index 000000000..5c66565f9 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumXY.ts @@ -0,0 +1,40 @@ +import { voronoi } from '@vx/voronoi'; +import { NearestDatumArgs } from '../types'; + +// finds the datum nearest to svgMouseX/Y using voronoi +export default function findNearestDatumXY< + Datum = unknown, + XScaleInput = unknown, + YScaleInput = unknown +>({ + width, + height, + xScale, + yScale, + xAccessor, + yAccessor, + svgMouseX, + svgMouseY, + data, +}: NearestDatumArgs) { + const scaledX = (d: unknown) => xScale(xAccessor(d)) as number; + const scaledY = (d: unknown) => yScale(yAccessor(d)) as number; + + // Create a voronoi with each node center points + const voronoiInstance = voronoi({ + x: scaledX, + y: scaledY, + width, + height, + }); + + const nearestDatum = voronoiInstance(data).find(svgMouseX, svgMouseY); + + if (!nearestDatum) return null; + + const { data: datum, index } = nearestDatum; + const distanceX = Math.abs(scaledX(datum) - svgMouseX); + const distanceY = Math.abs(scaledY(datum) - svgMouseY); + + return { datum, index, distanceX, distanceY }; +} diff --git a/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumY.ts b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumY.ts new file mode 100644 index 000000000..5ea96f260 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-chart-poc/src/util/findNearestDatumY.ts @@ -0,0 +1,27 @@ +import findNearestDatumSingleDimension from './findNearestDatumSingleDimension'; +import { NearestDatumArgs } from '../types'; + +export default function findNearestDatumY< + Datum = unknown, + XScaleInput = unknown, + YScaleInput = unknown +>({ + yScale: scale, + yAccessor: accessor, + svgMouseY: mouseCoord, + data, +}: NearestDatumArgs): { + datum: Datum; + index: number; + distanceX: number; + distanceY: number; +} | null { + const { datum, distance, index } = + findNearestDatumSingleDimension({ + scale, + accessor, + mouseCoord, + data, + }) ?? {}; + return datum ? { datum, index, distanceY: distance, distanceX: 0 } : null; +} diff --git a/packages/vx-xy-chart/.npmrc b/packages/vx-xy-chart/.npmrc new file mode 100644 index 000000000..9cf949503 --- /dev/null +++ b/packages/vx-xy-chart/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/vx-xy-chart/Readme.md b/packages/vx-xy-chart/Readme.md new file mode 100644 index 000000000..bf0f8299d --- /dev/null +++ b/packages/vx-xy-chart/Readme.md @@ -0,0 +1,15 @@ +# @vx/group + + + + + +`` provides a simplified API for SVG `` elements, which are containers for other SVG +objects. You may pass in a `top` and `left` margin (instead of `transform={translate(...)}`) and a +`className`. + +## Installation + +``` +npm install --save @vx/group +``` diff --git a/packages/vx-xy-chart/package.json b/packages/vx-xy-chart/package.json new file mode 100644 index 000000000..5c4748791 --- /dev/null +++ b/packages/vx-xy-chart/package.json @@ -0,0 +1,43 @@ +{ + "name": "@vx/xy-chart", + "version": "0.0.196", + "description": "React components for composing cartesian coordinate charts", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "lib", + "esm" + ], + "types": "lib/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/hshoff/vx.git" + }, + "keywords": [ + "vx", + "react", + "d3", + "visualizations", + "charts", + "svg" + ], + "author": "@williaster", + "license": "MIT", + "bugs": { + "url": "https://github.com/hshoff/vx/issues" + }, + "homepage": "https://github.com/hshoff/vx#readme", + "peerDependencies": { + "react": "^15.0.0-0 || ^16.0.0-0" + }, + "dependencies": { + "@types/classnames": "^2.2.9", + "@types/react": "*", + "classnames": "^2.2.5", + "prop-types": "^15.6.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/vx-xy-chart/src/index.ts b/packages/vx-xy-chart/src/index.ts new file mode 100644 index 000000000..aef22247d --- /dev/null +++ b/packages/vx-xy-chart/src/index.ts @@ -0,0 +1 @@ +export default 1; diff --git a/yarn.lock b/yarn.lock index b8bae5166..4d8a66a36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11283,7 +11283,7 @@ react-tilt@^0.1.4: resolved "https://registry.yarnpkg.com/react-tilt/-/react-tilt-0.1.4.tgz#0ac1f33674a3fff6c617cf411002d7ecdd2ebcb1" integrity sha512-bVeRumg+RIn6QN8S92UmubGqX/BG6/QeQISBeAcrS/70dpo/jVj+sjikIawDl5wTuPdubFH8zH0EMulWIctsnw== -react-use-measure@2.0.1: +react-use-measure@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.0.1.tgz#4f23f94c832cd4512da55acb300d1915dcbf3ae8" integrity sha512-lFfHiqcXbJ2/6aUkZwt8g5YYM7EGqNVxJhMqMPqv1BVXRKp8D7jYLlmma0SvhRY4WYxxkZpCdbJvhDylb5gcEA==