diff --git a/packages/vx-axis/package.json b/packages/vx-axis/package.json index a82ed862f..7af83a4e2 100644 --- a/packages/vx-axis/package.json +++ b/packages/vx-axis/package.json @@ -33,6 +33,7 @@ "@vx/group": "0.0.198", "@vx/point": "0.0.198", "@vx/shape": "0.0.198", + "@vx/scale": "0.0.198", "@vx/text": "0.0.198", "classnames": "^2.2.5", "prop-types": "^15.6.0" diff --git a/packages/vx-axis/src/axis/Axis.tsx b/packages/vx-axis/src/axis/Axis.tsx index c8f5a3b07..3571b8093 100644 --- a/packages/vx-axis/src/axis/Axis.tsx +++ b/packages/vx-axis/src/axis/Axis.tsx @@ -1,216 +1,86 @@ import React from 'react'; import cx from 'classnames'; -import { Line } from '@vx/shape'; -import { Point } from '@vx/point'; import { Group } from '@vx/group'; -import { Text } from '@vx/text'; -import center from '../utils/center'; -import getLabelTransform from '../utils/labelTransform'; -import ORIENT from '../constants/orientation'; -import toString from '../utils/toString'; -import toNumberOrUndefined from '../utils/toNumberOrUndefined'; -import { SharedAxisProps, AxisOrientation } from '../types'; +import { getTicks, coerceNumber } from '@vx/scale'; +import { SharedAxisProps, AxisScale } from '../types'; +import AxisRenderer from './AxisRenderer'; +import getTickPosition from '../utils/getTickPosition'; +import getTickFormatter from '../utils/getTickFormatter'; +import createPoint from '../utils/createPoint'; +import Orientation from '../constants/orientation'; -export type AxisProps = SharedAxisProps & { - orientation?: AxisOrientation; +export type AxisProps = SharedAxisProps & { + orientation?: Orientation; }; -export default function Axis({ - children, +export default function Axis({ + children = AxisRenderer, axisClassName, - axisLineClassName, hideAxisLine = false, hideTicks = false, hideZero = false, - label = '', - labelClassName, - labelOffset = 14, - labelProps = { - textAnchor: 'middle', - fontFamily: 'Arial', - fontSize: 10, - fill: '#222', - }, left = 0, numTicks = 10, - orientation = ORIENT.bottom, + orientation = Orientation.bottom, rangePadding = 0, scale, - stroke = '#222', - strokeWidth = 1, - strokeDasharray, - tickClassName, tickFormat, - tickLabelProps = (/** tickValue, index */) => ({ - textAnchor: 'middle', - fontFamily: 'Arial', - fontSize: 10, - fill: '#222', - }), tickLength = 8, - tickStroke = '#222', - tickTransform, tickValues, - tickComponent, top = 0, -}: AxisProps) { - const values = - tickValues || - (scale.ticks - ? scale.ticks(numTicks) - : scale - .domain() - .filter( - (_, index, arr) => - numTicks == null || - arr.length <= numTicks || - index % Math.round((arr.length - 1) / numTicks) === 0, - )); - const format = tickFormat || (scale.tickFormat ? scale.tickFormat() : toString); + ...restProps +}: AxisProps) { + const format = tickFormat ?? getTickFormatter(scale); - const range = scale.range(); - const range0 = Number(range[0]) + 0.5 - rangePadding; - const range1 = Number(range[range.length - 1]) + 0.5 + rangePadding; + const isLeft = orientation === Orientation.left; + const isTop = orientation === Orientation.top; + const horizontal = isTop || orientation === Orientation.bottom; - const isLeft = orientation === ORIENT.left; - const isTop = orientation === ORIENT.top; - const axisIsHorizontal = isTop || orientation === ORIENT.bottom; + const tickPosition = getTickPosition(scale); const tickSign = isLeft || isTop ? -1 : 1; - const position = center(scale.copy()); - - const axisFromPoint = new Point({ - x: axisIsHorizontal ? range0 : 0, - y: axisIsHorizontal ? 0 : range0, - }); - const axisToPoint = new Point({ - x: axisIsHorizontal ? range1 : 0, - y: axisIsHorizontal ? 0 : range1, - }); + const range = scale.range(); + const axisFromPoint = createPoint({ x: Number(range[0]) + 0.5 - rangePadding, y: 0 }, horizontal); + const axisToPoint = createPoint( + { x: Number(range[range.length - 1]) + 0.5 + rangePadding, y: 0 }, + horizontal, + ); - let tickLabelFontSize = 10; // track the max tick label size to compute label offset + const ticks = (tickValues ?? getTicks(scale, numTicks)) + .map((value, index) => ({ value, index })) + .filter(({ value }) => !hideZero || (value !== 0 && value !== '0')) + .map(({ value, index }) => { + const scaledValue = coerceNumber(tickPosition(value)); - if (children) { - return ( - - {children({ - axisFromPoint, - axisToPoint, - horizontal: axisIsHorizontal, - tickSign, - numTicks, - label, - rangePadding, - tickLength, - tickFormat: format, - tickPosition: position, - ticks: values.map((value, index) => { - const scaledValue = toNumberOrUndefined(position(value)); - const from = new Point({ - x: axisIsHorizontal ? scaledValue : 0, - y: axisIsHorizontal ? 0 : scaledValue, - }); - const to = new Point({ - x: axisIsHorizontal ? scaledValue : tickSign * tickLength, - y: axisIsHorizontal ? tickLength * tickSign : scaledValue, - }); - return { - value, - index, - from, - to, - formattedValue: format(value, index), - }; - }), - })} - - ); - } + return { + value, + index, + from: createPoint({ x: scaledValue, y: 0 }, horizontal), + to: createPoint({ x: scaledValue, y: tickLength * tickSign }, horizontal), + formattedValue: format(value, index), + }; + }); return ( - {values.map((val, index) => { - if ( - hideZero && - ((typeof val === 'number' && val === 0) || (typeof val === 'string' && val === '0')) - ) { - return null; - } - const scaledValue = toNumberOrUndefined(position(val)); - const tickFromPoint = new Point({ - x: axisIsHorizontal ? scaledValue : 0, - y: axisIsHorizontal ? 0 : scaledValue, - }); - const tickToPoint = new Point({ - x: axisIsHorizontal ? scaledValue : tickSign * tickLength, - y: axisIsHorizontal ? tickLength * tickSign : scaledValue, - }); - - const tickLabelPropsObj = tickLabelProps(val, index); - tickLabelFontSize = Math.max( - tickLabelFontSize, - (typeof tickLabelPropsObj.fontSize === 'number' && tickLabelPropsObj.fontSize) || 0, - ); - - const tickYCoord = tickToPoint.y + (axisIsHorizontal && !isTop ? tickLabelFontSize : 0); - const formattedValue = format(val, index); - return ( - - {!hideTicks && ( - - )} - {tickComponent ? ( - tickComponent({ - ...tickLabelPropsObj, - x: tickToPoint.x, - y: tickYCoord, - formattedValue, - }) - ) : ( - - {formattedValue} - - )} - - ); + {children({ + ...restProps, + axisFromPoint, + axisToPoint, + hideAxisLine, + hideTicks, + hideZero, + horizontal, + numTicks, + orientation, + rangePadding, + scale, + tickFormat: format, + tickLength, + tickPosition, + tickSign, + ticks, })} - - {!hideAxisLine && ( - - )} - - {label && ( - - {label} - - )} ); } diff --git a/packages/vx-axis/src/axis/AxisBottom.tsx b/packages/vx-axis/src/axis/AxisBottom.tsx index 3bde70bdb..9b3269034 100644 --- a/packages/vx-axis/src/axis/AxisBottom.tsx +++ b/packages/vx-axis/src/axis/AxisBottom.tsx @@ -1,31 +1,12 @@ import React from 'react'; import cx from 'classnames'; import Axis from './Axis'; -import ORIENT from '../constants/orientation'; -import { SharedAxisProps } from '../types'; +import Orientation from '../constants/orientation'; +import { SharedAxisProps, AxisScale } from '../types'; -export type AxisBottomProps = SharedAxisProps; - -export default function AxisBottom({ - children, +export default function AxisBottom({ axisClassName, - axisLineClassName, - hideAxisLine, - hideTicks, - hideZero, - label, - labelClassName, labelOffset = 8, - labelProps, - left, - numTicks, - rangePadding, - scale, - stroke, - strokeWidth, - strokeDasharray, - tickClassName, - tickFormat, tickLabelProps = (/** tickValue, index */) => ({ dy: '0.25em', fill: '#222', @@ -34,41 +15,16 @@ export default function AxisBottom({ textAnchor: 'middle', }), tickLength = 8, - tickStroke, - tickTransform, - tickValues, - tickComponent, - top, -}: AxisBottomProps) { + ...restProps +}: SharedAxisProps) { return ( ); } diff --git a/packages/vx-axis/src/axis/AxisLeft.tsx b/packages/vx-axis/src/axis/AxisLeft.tsx index 061bf7f92..d6ccdebf6 100644 --- a/packages/vx-axis/src/axis/AxisLeft.tsx +++ b/packages/vx-axis/src/axis/AxisLeft.tsx @@ -1,31 +1,12 @@ import React from 'react'; import cx from 'classnames'; import Axis from './Axis'; -import ORIENT from '../constants/orientation'; -import { SharedAxisProps } from '../types'; +import Orientation from '../constants/orientation'; +import { SharedAxisProps, AxisScale } from '../types'; -export type AxisLeftProps = SharedAxisProps; - -export default function AxisLeft({ - children, +export default function AxisLeft({ axisClassName, - axisLineClassName, - hideAxisLine, - hideTicks, - hideZero, - label, - labelClassName, labelOffset = 36, - labelProps, - left, - numTicks, - rangePadding, - scale, - stroke, - strokeWidth, - strokeDasharray, - tickClassName, - tickFormat, tickLabelProps = (/** tickValue, index */) => ({ dx: '-0.25em', dy: '0.25em', @@ -35,41 +16,16 @@ export default function AxisLeft({ textAnchor: 'end', }), tickLength = 8, - tickStroke, - tickTransform, - tickValues, - tickComponent, - top, -}: AxisLeftProps) { + ...restProps +}: SharedAxisProps) { return ( ); } diff --git a/packages/vx-axis/src/axis/AxisRenderer.tsx b/packages/vx-axis/src/axis/AxisRenderer.tsx new file mode 100644 index 000000000..594ad238d --- /dev/null +++ b/packages/vx-axis/src/axis/AxisRenderer.tsx @@ -0,0 +1,109 @@ +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'; + +const defaultTextProps: Partial = { + textAnchor: 'middle', + fontFamily: 'Arial', + fontSize: 10, + fill: '#222', +}; + +export default function AxisRenderer({ + axisFromPoint, + axisLineClassName, + axisToPoint, + hideAxisLine, + hideTicks, + horizontal, + label = '', + labelClassName, + labelOffset = 14, + labelProps = defaultTextProps, + orientation, + scale, + stroke = '#222', + strokeWidth = 1, + strokeDasharray, + tickClassName, + tickComponent, + tickLabelProps = (/** tickValue, index */) => defaultTextProps, + tickLength, + tickStroke = '#222', + tickTransform, + ticks, +}: AxisRendererProps) { + let tickLabelFontSize = 10; // track the max tick label size to compute label offset + + 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} + + )} + + ); + })} + + {!hideAxisLine && ( + + )} + + {label && ( + + {label} + + )} + + ); +} diff --git a/packages/vx-axis/src/axis/AxisRight.tsx b/packages/vx-axis/src/axis/AxisRight.tsx index 510d36ee0..636c026b6 100644 --- a/packages/vx-axis/src/axis/AxisRight.tsx +++ b/packages/vx-axis/src/axis/AxisRight.tsx @@ -1,31 +1,14 @@ import React from 'react'; import cx from 'classnames'; import Axis from './Axis'; -import ORIENT from '../constants/orientation'; -import { SharedAxisProps } from '../types'; +import Orientation from '../constants/orientation'; +import { SharedAxisProps, AxisScale } from '../types'; -export type AxisRightProps = SharedAxisProps; +export type AxisRightProps = SharedAxisProps; -export default function AxisRight({ - children, +export default function AxisRight({ axisClassName, - axisLineClassName, - hideAxisLine, - hideTicks, - hideZero, - label, - labelClassName, labelOffset = 36, - labelProps, - left, - numTicks, - rangePadding, - scale, - stroke, - strokeWidth, - strokeDasharray, - tickClassName, - tickFormat, tickLabelProps = (/** tickValue, index */) => ({ dx: '0.25em', dy: '0.25em', @@ -35,41 +18,16 @@ export default function AxisRight({ textAnchor: 'start', }), tickLength = 8, - tickStroke, - tickTransform, - tickValues, - tickComponent, - top, -}: AxisRightProps) { + ...restProps +}: AxisRightProps) { return ( ); } diff --git a/packages/vx-axis/src/axis/AxisTop.tsx b/packages/vx-axis/src/axis/AxisTop.tsx index 0c6302420..11ef9570f 100644 --- a/packages/vx-axis/src/axis/AxisTop.tsx +++ b/packages/vx-axis/src/axis/AxisTop.tsx @@ -1,31 +1,14 @@ import React from 'react'; import cx from 'classnames'; import Axis from './Axis'; -import ORIENT from '../constants/orientation'; -import { SharedAxisProps } from '../types'; +import Orientation from '../constants/orientation'; +import { SharedAxisProps, AxisScale } from '../types'; -export type AxisTopProps = SharedAxisProps; +export type AxisTopProps = SharedAxisProps; -export default function AxisTop({ - children, +export default function AxisTop({ axisClassName, - axisLineClassName, - hideAxisLine, - hideTicks, - hideZero, - label, - labelClassName, labelOffset = 8, - labelProps, - left, - numTicks, - rangePadding, - scale, - stroke, - strokeWidth, - strokeDasharray, - tickClassName, - tickFormat, tickLabelProps = (/** tickValue, index */) => ({ dy: '-0.25em', fill: '#222', @@ -34,41 +17,16 @@ export default function AxisTop({ textAnchor: 'middle', }), tickLength = 8, - tickStroke, - tickTransform, - tickValues, - tickComponent, - top, -}: AxisTopProps) { + ...restProps +}: AxisTopProps) { return ( ); } diff --git a/packages/vx-axis/src/constants/orientation.ts b/packages/vx-axis/src/constants/orientation.ts index 57189db18..a2beb4031 100644 --- a/packages/vx-axis/src/constants/orientation.ts +++ b/packages/vx-axis/src/constants/orientation.ts @@ -1,10 +1,12 @@ -import { AxisOrientation } from '../types'; +import { ValueOf } from '@vx/scale'; -const orientation: Record = { +const Orientation = { top: 'top', left: 'left', right: 'right', bottom: 'bottom', -}; +} as const; -export default orientation; +type Orientation = ValueOf; + +export default Orientation; diff --git a/packages/vx-axis/src/index.ts b/packages/vx-axis/src/index.ts index 837ff5d15..0e45a4361 100644 --- a/packages/vx-axis/src/index.ts +++ b/packages/vx-axis/src/index.ts @@ -4,3 +4,5 @@ export { default as AxisRight } from './axis/AxisRight'; export { default as AxisTop } from './axis/AxisTop'; export { default as AxisBottom } from './axis/AxisBottom'; export { default as Orientation } from './constants/orientation'; + +export * from './types'; diff --git a/packages/vx-axis/src/types.ts b/packages/vx-axis/src/types.ts index 5e91f5e0f..c9ed57652 100644 --- a/packages/vx-axis/src/types.ts +++ b/packages/vx-axis/src/types.ts @@ -1,12 +1,21 @@ +import { D3Scale, NumberLike, ScaleInput } from '@vx/scale'; import { TextProps } from '@vx/text/lib/Text'; +import Orientation from './constants/orientation'; -export type AxisOrientation = 'top' | 'right' | 'bottom' | 'left'; +// In order to plot values on an axis, output of the scale must be number. +// Some scales return undefined. +export type AxisScaleOutput = number | NumberLike | undefined; + +/** A catch-all type for scales that are compatible with axis */ +export type AxisScale = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + D3Scale; -export type FormattedValue = string | number | undefined; +type FormattedValue = string | undefined; -export type TickFormatter = (value: ScaleInput, tickIndex: number) => FormattedValue; +export type TickFormatter = (value: T, index: number) => FormattedValue; -export type TickLabelProps = (val: ScaleInput, index: number) => Partial; +export type TickLabelProps = (value: T, index: number) => Partial; export type TickRendererProps = Partial & { x: number; @@ -14,17 +23,15 @@ export type TickRendererProps = Partial & { formattedValue: FormattedValue; }; -export type SharedAxisProps = { - /** The class name applied to the outermost axis group element. */ - axisClassName?: string; +interface CommonProps { /** The class name applied to the axis line element. */ axisLineClassName?: string; /** If true, will hide the axis line. */ - hideAxisLine?: boolean; + hideAxisLine: boolean; /** If true, will hide the ticks (but not the tick labels). */ - hideTicks?: boolean; + hideTicks: boolean; /** If true, will hide the '0' value tick and tick label. */ - hideZero?: boolean; + hideZero: boolean; /** The text for the axis label. */ label?: string; /** The class name applied to the axis label text element. */ @@ -33,14 +40,12 @@ export type SharedAxisProps = { labelOffset?: number; /** Props applied to the axis label component. */ labelProps?: Partial; - /** A left pixel offset applied to the entire axis. */ - left?: number; /** The number of ticks wanted for the axis (note this is approximate) */ - numTicks?: number; + numTicks: number; + /** Placement of the axis */ + orientation: Orientation; /** Pixel padding to apply to both sides of the axis. */ - rangePadding?: number; - /** A [d3](https://github.com/d3/d3-scale) or [vx](https://github.com/hshoff/vx/tree/master/packages/vx-scale) scale function. */ - scale: GenericScale; + rangePadding: number; /** The color for the stroke of the lines. */ stroke?: string; /** The pixel value for the width of the lines. */ @@ -49,75 +54,59 @@ export type SharedAxisProps = { 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) */ + tickComponent?: (tickRendererProps: TickRendererProps) => React.ReactNode; /** A [d3 formatter](https://github.com/d3/d3-scale/blob/master/README.md#continuous_tickFormat) for the tick text. */ - tickFormat?: TickFormatter; + tickFormat: TickFormatter>; /** A function that returns props for a given tick label. */ - tickLabelProps?: TickLabelProps; + tickLabelProps?: TickLabelProps>; /** The length of the tick lines. */ - tickLength?: number; + tickLength: number; /** The color for the tick's stroke value. */ tickStroke?: string; /** A custom SVG transform value to be applied to each tick group. */ tickTransform?: string; - /** An array of values that determine the number and values of the ticks. Falls back to `scale.ticks()` or `.domain()`. */ - tickValues?: ScaleInput[]; - /** Override the component used to render tick labels (instead of from @vx/text) */ - tickComponent?: (tickRendererProps: TickRendererProps) => React.ReactNode; - /** A top pixel offset applied to the entire axis. */ - top?: number; - /** For more control over rendering or to add event handlers to datum, pass a function as children. */ - children?: (renderProps: ChildRenderProps) => React.ReactNode; -}; - -// In order to plot values on an axis, Output must be numeric or coercible to a number. -// Some scales return undefined. -export type ScaleOutput = number | { valueOf(): number } | undefined; - -export type GenericScale = - | ScaleNoRangeRound - | ScaleWithRangeRound; - -interface ScaleNoRangeRound { - (value: ScaleInput): ScaleOutput | [ScaleOutput, ScaleOutput]; // quantize scales return an array - domain(): ScaleInput[] | [ScaleInput, ScaleInput]; - domain(scaleInput: ScaleInput[] | [ScaleInput, ScaleInput]): any; // we can't capture the copy of the type accurately - range(): ScaleOutput[] | [ScaleOutput, ScaleOutput]; - range(scaleOutput: ScaleOutput[] | [ScaleOutput, ScaleOutput]): any; - ticks?: (count: number) => ScaleInput[] | [ScaleInput, ScaleInput]; - bandwidth?: () => number; - round?: () => boolean; - tickFormat?: () => (input: ScaleInput) => FormattedValue; - copy(): this; } -// We cannot have optional methods AND overloads, so define a separate type for rangeRound -interface ScaleWithRangeRound extends ScaleNoRangeRound { - rangeRound(): ScaleOutput[] | [ScaleOutput, ScaleOutput]; - rangeRound(scaleOutput: ScaleOutput[] | [ScaleOutput, ScaleOutput]): any; -} - -export interface Point { +interface Point { x: number; y: number; } -export type ChildRenderProps = { +export type AxisRendererProps = CommonProps & { + /** Start point of the axis line */ axisFromPoint: Point; + /** End point of the axis line */ axisToPoint: Point; + /** Whether this axis is horizontal */ horizontal: boolean; + /** A [d3](https://github.com/d3/d3-scale) or [vx](https://github.com/hshoff/vx/tree/master/packages/vx-scale) scale function. */ + scale: Scale; + /** Function to compute tick position along the axis from tick value */ + tickPosition: (value: ScaleInput) => AxisScaleOutput; /** Axis coordinate sign, -1 for left or top orientation. */ tickSign: 1 | -1; - numTicks: number; - label?: string; - rangePadding: number; - tickLength: number; - tickFormat: TickFormatter; - tickPosition: (value: ScaleInput) => ScaleOutput; + /** Computed ticks with positions and formatted value */ ticks: { - value: ScaleInput; + value: ScaleInput; index: number; from: Point; to: Point; formattedValue: FormattedValue; }[]; }; + +export type SharedAxisProps = Partial> & { + /** The class name applied to the outermost axis group element. */ + axisClassName?: string; + /** A left pixel offset applied to the entire axis. */ + left?: number; + /** A [d3](https://github.com/d3/d3-scale) or [vx](https://github.com/hshoff/vx/tree/master/packages/vx-scale) scale function. */ + scale: Scale; + /** An array of values that determine the number and values of the ticks. Falls back to `scale.ticks()` or `.domain()`. */ + tickValues?: ScaleInput[]; + /** A top pixel offset applied to the entire axis. */ + top?: number; + /** For more control over rendering or to add event handlers to datum, pass a function as children. */ + children?: (renderProps: AxisRendererProps) => React.ReactNode; +}; diff --git a/packages/vx-axis/src/utils/center.ts b/packages/vx-axis/src/utils/center.ts deleted file mode 100644 index 83683be4c..000000000 --- a/packages/vx-axis/src/utils/center.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { GenericScale } from '../types'; - -/** - * Returns a function that applies a centering transform to a scaled value, - * if `Output` is of type `number` and `scale.bandwidth()` is defined - */ -export default function center(scale: GenericScale) { - let offset = scale.bandwidth ? scale.bandwidth() / 2 : 0; - if (scale.round && scale.round()) offset = Math.round(offset); - - return (d: ScaleInput) => { - const scaledValue = scale(d); - if (typeof scaledValue === 'number') return scaledValue + offset; - // quantize scales return an array of values - if (Array.isArray(scaledValue)) return Number(scaledValue[0]) + offset; - return scaledValue; - }; -} diff --git a/packages/vx-axis/src/utils/createPoint.ts b/packages/vx-axis/src/utils/createPoint.ts new file mode 100644 index 000000000..4bb963b6e --- /dev/null +++ b/packages/vx-axis/src/utils/createPoint.ts @@ -0,0 +1,5 @@ +import { Point } from '@vx/point'; + +export default function createPoint({ x, y }: Partial, horizontal: boolean) { + return new Point(horizontal ? { x, y } : { x: y, y: x }); +} diff --git a/packages/vx-axis/src/utils/labelTransform.ts b/packages/vx-axis/src/utils/getLabelTransform.ts similarity index 62% rename from packages/vx-axis/src/utils/labelTransform.ts rename to packages/vx-axis/src/utils/getLabelTransform.ts index ce964d543..dd0f65f1c 100644 --- a/packages/vx-axis/src/utils/labelTransform.ts +++ b/packages/vx-axis/src/utils/getLabelTransform.ts @@ -1,17 +1,17 @@ import { TextProps } from '@vx/text/lib/Text'; -import ORIENT from '../constants/orientation'; -import { AxisOrientation, ScaleOutput } from '../types'; +import Orientation from '../constants/orientation'; +import { AxisScaleOutput } from '../types'; export interface TransformArgs { labelOffset: number; labelProps: Partial; - orientation: AxisOrientation; - range: ScaleOutput[]; + orientation: Orientation; + range: AxisScaleOutput[]; tickLabelFontSize: number; tickLength: number; } -export default function labelTransform({ +export default function getLabelTransform({ labelOffset, labelProps, orientation, @@ -19,15 +19,15 @@ export default function labelTransform({ tickLabelFontSize, tickLength, }: TransformArgs) { - const sign = orientation === ORIENT.left || orientation === ORIENT.top ? -1 : 1; + const sign = orientation === Orientation.left || orientation === Orientation.top ? -1 : 1; let x; let y; let transform; - if (orientation === ORIENT.top || orientation === ORIENT.bottom) { + if (orientation === Orientation.top || orientation === Orientation.bottom) { const yBottomOffset = - orientation === ORIENT.bottom && typeof labelProps.fontSize === 'number' + orientation === Orientation.bottom && typeof labelProps.fontSize === 'number' ? labelProps.fontSize : 0; diff --git a/packages/vx-axis/src/utils/getTickFormatter.ts b/packages/vx-axis/src/utils/getTickFormatter.ts new file mode 100644 index 000000000..9e4687397 --- /dev/null +++ b/packages/vx-axis/src/utils/getTickFormatter.ts @@ -0,0 +1,18 @@ +import { ScaleInput, toString } from '@vx/scale'; +import { TickFormatter, AxisScale } from '../types'; + +/** + * Returns a tick position for the given tick value + */ +export default function getTickFormatter(scale: Scale) { + // Broaden type before using 'xxx' in s as typeguard. + const s = scale as AxisScale; + + // For point or band scales, + // have to add offset to make the tick centered. + if ('tickFormat' in s) { + return s.tickFormat() as TickFormatter>; + } + + return toString as TickFormatter>; +} diff --git a/packages/vx-axis/src/utils/getTickPosition.ts b/packages/vx-axis/src/utils/getTickPosition.ts new file mode 100644 index 000000000..5d2006b54 --- /dev/null +++ b/packages/vx-axis/src/utils/getTickPosition.ts @@ -0,0 +1,28 @@ +import { ScaleInput } from '@vx/scale'; +import { AxisScale, AxisScaleOutput } from '../types'; + +/** + * Create a function that returns a tick position for the given tick value + */ +export default function getTickPosition( + scale: Scale, + align: 'start' | 'center' | 'end' = 'center', +) { + // Broaden type before using 'xxx' in s as typeguard. + const s = scale as AxisScale; + + // For point or band scales, + // have to add offset to make the tick at center or end. + if (align !== 'start' && 'bandwidth' in s) { + let offset = s.bandwidth(); + if (align === 'center') offset /= 2; + if (s.round()) offset = Math.round(offset); + return (d: ScaleInput) => { + const scaledValue = s(d); + + return typeof scaledValue === 'number' ? scaledValue + offset : scaledValue; + }; + } + + return scale as (d: ScaleInput) => AxisScaleOutput; +} diff --git a/packages/vx-axis/src/utils/toNumberOrUndefined.ts b/packages/vx-axis/src/utils/toNumberOrUndefined.ts deleted file mode 100644 index 46beaafe0..000000000 --- a/packages/vx-axis/src/utils/toNumberOrUndefined.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default function toNumberOrUndefined( - val?: number | { valueOf(): number }, -): number | undefined { - if (typeof val === 'undefined') return val; - return Number(val); -} diff --git a/packages/vx-axis/src/utils/toString.ts b/packages/vx-axis/src/utils/toString.ts deleted file mode 100644 index aab868cc9..000000000 --- a/packages/vx-axis/src/utils/toString.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function toString(x?: T) { - return x && x.toString(); -} diff --git a/packages/vx-axis/test/Axis.test.tsx b/packages/vx-axis/test/Axis.test.tsx index 40f49b5ca..15b749519 100644 --- a/packages/vx-axis/test/Axis.test.tsx +++ b/packages/vx-axis/test/Axis.test.tsx @@ -5,7 +5,6 @@ import { Line } from '@vx/shape'; import { Text } from '@vx/text'; import { scaleBand, scaleLinear } from '@vx/scale'; import { Axis } from '../src'; -import { GenericScale } from '../src/types'; const axisProps = { orientation: 'left' as const, @@ -13,7 +12,7 @@ const axisProps = { range: [10, 0], round: true, domain: [0, 10], - }) as GenericScale, + }), label: 'test axis', }; @@ -186,26 +185,30 @@ describe('', () => { }); test('tickFormat should have access to tick index', () => { - const wrapper = shallow( i} />); + const wrapper = shallow( + `${i}`} />, + ); expect( wrapper .children() .find('.vx-axis-tick') .find(Text) .prop('children'), - ).toBe(0); + ).toBe('0'); }); test('it should use center if scale is band', () => { - const overrideAxisProps = { - orientation: 'bottom' as const, - scale: scaleBand({ - range: [10, 0], - round: true, - domain: ['a', 'b'], - }), - }; - const wrapper = shallow(); + const wrapper = shallow( + , + ); const points = wrapper.children().find(Line); // First point expect(points.at(0).prop('from')).toEqual({ x: 8, y: 0 }); diff --git a/packages/vx-axis/test/AxisBottom.test.tsx b/packages/vx-axis/test/AxisBottom.test.tsx index 1a4752716..d75a2a65d 100644 --- a/packages/vx-axis/test/AxisBottom.test.tsx +++ b/packages/vx-axis/test/AxisBottom.test.tsx @@ -3,14 +3,13 @@ import { shallow } from 'enzyme'; import { scaleLinear } from '../../vx-scale/src'; import { Axis, AxisBottom } from '../src'; -import { GenericScale } from '../src/types'; const axisProps = { scale: scaleLinear({ range: [10, 0], round: true, domain: [0, 10], - }) as GenericScale, + }), }; describe('', () => { diff --git a/packages/vx-axis/test/AxisLeft.test.tsx b/packages/vx-axis/test/AxisLeft.test.tsx index e943d3f97..2037614d3 100644 --- a/packages/vx-axis/test/AxisLeft.test.tsx +++ b/packages/vx-axis/test/AxisLeft.test.tsx @@ -3,14 +3,13 @@ import { shallow } from 'enzyme'; import { scaleLinear } from '../../vx-scale/src'; import { Axis, AxisLeft } from '../src'; -import { GenericScale } from '../src/types'; const axisProps = { scale: scaleLinear({ range: [10, 0], round: true, domain: [0, 10], - }) as GenericScale, + }), }; describe('', () => { diff --git a/packages/vx-axis/test/AxisRight.test.tsx b/packages/vx-axis/test/AxisRight.test.tsx index 50dc26d75..c66dc4b5c 100644 --- a/packages/vx-axis/test/AxisRight.test.tsx +++ b/packages/vx-axis/test/AxisRight.test.tsx @@ -3,14 +3,13 @@ import { shallow } from 'enzyme'; import { scaleLinear } from '../../vx-scale/src'; import { Axis, AxisRight } from '../src'; -import { GenericScale } from '../src/types'; const axisProps = { scale: scaleLinear({ range: [10, 0], round: true, domain: [0, 10], - }) as GenericScale, + }), }; describe('', () => { diff --git a/packages/vx-axis/test/AxisTop.test.tsx b/packages/vx-axis/test/AxisTop.test.tsx index 74a6b4698..d7fc5ac0e 100644 --- a/packages/vx-axis/test/AxisTop.test.tsx +++ b/packages/vx-axis/test/AxisTop.test.tsx @@ -3,14 +3,13 @@ import { shallow } from 'enzyme'; import { scaleLinear } from '../../vx-scale/src'; import { Axis, AxisTop } from '../src'; -import { GenericScale } from '../src/types'; const axisProps = { scale: scaleLinear({ range: [10, 0], round: true, domain: [0, 10], - }) as GenericScale, + }), }; describe('', () => { diff --git a/packages/vx-axis/test/scales.test.tsx b/packages/vx-axis/test/scales.test.tsx index 979d95c9b..0777f54d3 100644 --- a/packages/vx-axis/test/scales.test.tsx +++ b/packages/vx-axis/test/scales.test.tsx @@ -15,150 +15,181 @@ import { scaleUtc, } from '@vx/scale'; import { Axis } from '../src'; -import { GenericScale } from '../src/types'; const axisProps = { orientation: 'left' as const, label: 'test axis', }; -function setup(scale: GenericScale) { - return () => shallow(); -} - describe('Axis scales', () => { it('should render with scaleBand', () => { - expect( - setup( - scaleBand({ - range: [10, 0], - round: true, - domain: ['a', 'b', 'c'], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleLinear', () => { - expect( - setup( - scaleLinear({ - range: [10, 0], - round: true, - domain: [0, 10], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleLog', () => { - expect( - setup( - scaleLog({ - range: [10, 0], - round: true, - domain: [1, 10, 100, 1000], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleOrdinal', () => { - expect( - setup( - scaleOrdinal({ - range: [0, 10], - domain: ['a', 'b', 'c'], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scalePoint', () => { - expect( - setup( - scalePoint({ - range: [0, 10], - round: true, - domain: ['a', 'b', 'c'], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scalePower', () => { - expect( - setup( - scalePower({ - range: [1, 2, 3, 4, 5], - domain: [1, 10, 100, 1000, 10000], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleQuantile', () => { - expect( - setup( - scaleQuantile({ - range: [0, 2, 4, 6, 8, 10], - domain: [1, 10, 100, 1000, 10000], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleQuantize', () => { - expect( - setup( - scaleQuantize({ - range: [1, 10], - domain: [1, 10], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleSymlog', () => { - expect( - setup( - scaleSymlog({ - range: [1, 10], - domain: [1, 10], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleThreshold', () => { - expect( - setup( - scaleThreshold({ - range: [1, 10], - domain: [1, 10], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleTime', () => { - expect( - setup( - scaleTime({ - range: [1, 10], - domain: [new Date('2020-01-01'), new Date('2020-01-05')], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); it('should render with scaleUtc', () => { - expect( - setup( - scaleUtc({ - range: [1, 10], - domain: [new Date('2020-01-01'), new Date('2020-01-05')], - }) as GenericScale, + expect(() => + shallow( + , ), ).not.toThrow(); }); diff --git a/packages/vx-axis/test/utils/getTickPosition.test.ts b/packages/vx-axis/test/utils/getTickPosition.test.ts new file mode 100644 index 000000000..92d2701bb --- /dev/null +++ b/packages/vx-axis/test/utils/getTickPosition.test.ts @@ -0,0 +1,42 @@ +import { scaleLinear, scaleBand } from '@vx/scale'; +import getTickPosition from '../../src/utils/getTickPosition'; + +describe('getTickPosition(scale, align)', () => { + describe('scales without band', () => { + it('return center position', () => { + const position = getTickPosition(scaleLinear({ domain: [0, 10], range: [0, 100] })); + expect(position(5)).toEqual(50); + }); + }); + describe('scales with band', () => { + describe('align', () => { + const scale = scaleBand({ domain: ['a', 'b', 'c'], range: [0, 100] }); + + it('default to center', () => { + expect(getTickPosition(scale)('b')).toEqual(50); + }); + it('center', () => { + expect(getTickPosition(scale, 'center')('b')).toEqual(50); + }); + it('start', () => { + expect((getTickPosition(scale, 'start')('b') as number).toFixed(2)).toEqual('33.33'); + }); + it('end', () => { + expect((getTickPosition(scale, 'end')('b') as number).toFixed(2)).toEqual('66.67'); + }); + }); + describe('with rounding', () => { + const scale = scaleBand({ domain: ['a', 'b', 'c'], range: [0, 100], round: true }); + + it('center', () => { + expect(getTickPosition(scale, 'center')('b')).toEqual(51); + }); + it('start', () => { + expect(getTickPosition(scale, 'start')('b')).toEqual(34); + }); + it('end', () => { + expect(getTickPosition(scale, 'end')('b')).toEqual(67); + }); + }); + }); +}); diff --git a/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx b/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx index 50a7e77f1..452fb156d 100644 --- a/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx +++ b/packages/vx-demo/src/sandboxes/vx-axis/Example.tsx @@ -2,8 +2,8 @@ import React 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 } from '@vx/scale'; -import { AxisBottom } from '@vx/axis'; +import { scaleUtc, scaleLinear, scaleLog, scaleBand, ScaleInput } from '@vx/scale'; +import { AxisBottom, SharedAxisProps, AxisScale } from '@vx/axis'; import { LinearGradient } from '@vx/gradient'; import { timeFormat } from 'd3-time-format'; @@ -25,10 +25,6 @@ export type AxisProps = { height: number; }; -type Scale = any; - -type ScaleInput = any; - export default function Example({ width: outerWidth = 800, height: outerHeight = 800 }: AxisProps) { // in svg, margin is subtracted from total width/height const width = outerWidth - margin.left - margin.right; @@ -36,19 +32,18 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = if (width < 10) return null; - const scales: { - scale: Scale; - values: ScaleInput[]; - label: string; - tickFormat: (value: ScaleInput, idx: number) => string | number; - }[] = [ + interface AxisDemoProps extends SharedAxisProps { + values: ScaleInput[]; + } + + const axes: AxisDemoProps>[] = [ { scale: scaleLinear({ domain: [0, 10], range: [0, width], }), values: [0, 2, 4, 6, 8, 10], - tickFormat: (v: number) => (v === 10 ? 'last' : (v === 0 && 'first') || v), + tickFormat: (v: number) => (v === 10 ? 'last' : (v === 0 && 'first') || `${v}`), label: 'linear', }, { @@ -59,7 +54,7 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = paddingInner: 1, }), values: ['a', 'b', 'c', 'd'], - tickFormat: (v: number) => v, + tickFormat: (v: string) => v, label: 'categories', }, { @@ -78,13 +73,13 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = range: [0, width], }), values: [1, 10, 100, 1000, 10000], - tickFormat: (v: number) => (`${v}`[0] === '1' ? v : ''), + tickFormat: (v: number) => (`${v}`[0] === '1' ? `${v}` : ''), label: 'log', }, ]; const scalePadding = 50; - const scaleHeight = height / scales.length - scalePadding; + const scaleHeight = height / axes.length - scalePadding; const yScale = scaleLinear({ domain: [100, 0], @@ -108,11 +103,11 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = rx={14} /> - {scales.map(({ scale, values, label, tickFormat }, i) => ( + {axes.map(({ scale, values, label, tickFormat }, i) => ( [ - scale(x) + + (scale(x) ?? 0) + ('bandwidth' in scale && typeof scale!.bandwidth !== 'undefined' ? scale.bandwidth!() / 2 : 0), @@ -123,7 +118,7 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = fill={gridColor} fillOpacity={0.2} /> - + xScale={scale} yScale={yScale} stroke={gridColor} @@ -132,7 +127,7 @@ export default function Example({ width: outerWidth = 800, height: outerHeight = numTicksRows={2} numTicksColumns={numTickColumns} /> - + ( ) } - + ( dy: '0.33em', })} /> - + - + ; + yScale: AxisScale; width: number; yMax: number; margin: { top: number; right: number; bottom: number; left: number }; @@ -76,7 +75,7 @@ export default function AreaChart({ curve={curveMonotoneX} /> {!hideBottomAxis && ( - + 520 ? 10 : 5} @@ -86,7 +85,7 @@ export default function AreaChart({ /> )} {!hideLeftAxis && ( - + = ValueOf>; + +/** + * A catch-all type for all D3 scales. + * + * Use this instead of `D3Scale` + * unless other generic types (`Output`, `DiscreteInput` and `ThresholdInput`) + * are also included and passed to `D3Scale`. + * Otherwise it may not match some scales (band, point, threshold) correctly and cause TS errors. + * + * Example error messages: + * * "Type 'StringLike' is not assignable to type 'string'" + * * "Type 'number' is not assignable to type 'DefaultThresholdInput'" + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyD3Scale = D3Scale; + +export type InferD3ScaleOutput = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Scale extends D3Scale ? X : DefaultOutput; + +export type InferD3ScaleDiscreteInput = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Scale extends D3Scale ? X : StringLike; + +export type InferD3ScaleThresholdInput = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Scale extends D3Scale ? X : DefaultThresholdInput; + +/** Get type of scale input from D3 scale */ +export type ScaleInput = Parameters[0]; diff --git a/packages/vx-scale/src/utils/coerceNumber.ts b/packages/vx-scale/src/utils/coerceNumber.ts new file mode 100644 index 000000000..a9387aabc --- /dev/null +++ b/packages/vx-scale/src/utils/coerceNumber.ts @@ -0,0 +1,10 @@ +import { NumberLike } from '../types/Base'; + +export default function coerceNumber(val: T | NumberLike): T | number { + if ((typeof val === 'function' || (typeof val === 'object' && !!val)) && 'valueOf' in val) { + const num = val.valueOf(); + if (typeof num === 'number') return num; + } + + return val as T; +} diff --git a/packages/vx-scale/src/utils/getTicks.ts b/packages/vx-scale/src/utils/getTicks.ts new file mode 100644 index 000000000..96699030b --- /dev/null +++ b/packages/vx-scale/src/utils/getTicks.ts @@ -0,0 +1,25 @@ +import { AnyD3Scale, ScaleInput } from '../types/Scale'; + +export default function getTicks( + scale: Scale, + numTicks?: number, +): ScaleInput[] { + // Because `Scale` is generic type which maybe a subset of AnyD3Scale + // that may not have `ticks` field, + // TypeScript will not let us do the `'ticks' in scale` check directly. + // Have to manually cast and expand type first. + const s = scale as AnyD3Scale; + + if ('ticks' in s) { + return s.ticks(numTicks); + } + + return s + .domain() + .filter( + (_, index, arr) => + numTicks == null || + arr.length <= numTicks || + index % Math.round((arr.length - 1) / numTicks) === 0, + ); +} diff --git a/packages/vx-scale/src/utils/toString.ts b/packages/vx-scale/src/utils/toString.ts new file mode 100644 index 000000000..e07e8f06e --- /dev/null +++ b/packages/vx-scale/src/utils/toString.ts @@ -0,0 +1,5 @@ +import { StringLike } from '../types/Base'; + +export default function toString(x?: T) { + return x?.toString(); +} diff --git a/packages/vx-scale/test/utils/coerceNumber.test.ts b/packages/vx-scale/test/utils/coerceNumber.test.ts new file mode 100644 index 000000000..08fe28a90 --- /dev/null +++ b/packages/vx-scale/test/utils/coerceNumber.test.ts @@ -0,0 +1,16 @@ +import coerceNumber from '../../src/utils/coerceNumber'; + +describe('coerceNumber(mayBeNumberLike)', () => { + it('coerces NumberLike to number', () => { + expect(coerceNumber({ valueOf: () => 1 })).toEqual(1); + expect(coerceNumber(new Date(10))).toEqual(10); + }); + it('returns the same thing if not', () => { + expect(coerceNumber('x')).toEqual('x'); + expect(coerceNumber(2)).toEqual(2); + expect(coerceNumber(0)).toEqual(0); + expect(coerceNumber(null)).toBeNull(); + expect(coerceNumber(undefined)).toBeUndefined(); + expect(coerceNumber({ x: 1 })).toEqual({ x: 1 }); + }); +}); diff --git a/packages/vx-scale/test/utils/getTicks.test.ts b/packages/vx-scale/test/utils/getTicks.test.ts new file mode 100644 index 000000000..59fb4b13e --- /dev/null +++ b/packages/vx-scale/test/utils/getTicks.test.ts @@ -0,0 +1,19 @@ +import getTicks from '../../src/utils/getTicks'; +import { scaleLinear, scaleBand } from '../../lib'; + +describe('getTicks(scale)', () => { + it('linear', () => { + const scale = scaleLinear(); + expect(getTicks(scale, 3)).toEqual([0, 0.5, 1]); + expect(getTicks(scale, 2)).toEqual([0, 0.5, 1]); + expect(getTicks(scale, 1)).toEqual([0, 1]); + }); + it('band', () => { + const scale = scaleBand({ + domain: ['a', 'b', 'c', 'd'], + }); + expect(getTicks(scale, 4)).toEqual(['a', 'b', 'c', 'd']); + expect(getTicks(scale, 3)).toEqual(['a', 'b', 'c', 'd']); + expect(getTicks(scale, 2)).toEqual(['a', 'c']); + }); +}); diff --git a/packages/vx-scale/test/utils/toString.test.ts b/packages/vx-scale/test/utils/toString.test.ts new file mode 100644 index 000000000..09e794e3b --- /dev/null +++ b/packages/vx-scale/test/utils/toString.test.ts @@ -0,0 +1,14 @@ +import toString from '../../src/utils/toString'; + +describe('toString(mayBeStringLike)', () => { + it('converts StringLike to string', () => { + expect(toString({ toString: () => 'haha' })).toEqual('haha'); + expect(toString('x')).toEqual('x'); + expect(toString(2)).toEqual('2'); + expect(toString(0)).toEqual('0'); + expect(toString({ x: 1 })).toEqual('[object Object]'); + }); + it('returns the same thing if not', () => { + expect(toString(undefined)).toBeUndefined(); + }); +});