Skip to content

Commit

Permalink
new(xychart): handle rendering + tweening missing values (airbnb#898)
Browse files Browse the repository at this point in the history
* new(xychart): move horizontal to DataProvider, add utils/isDiscreteScale

* deps(xychart): add d3-interpolate-path

* deps: update yarn.lock

* new(demo/xychart): add missing data + fewer data controls, remove horizontal props

* internal(xychart): use d3-interpolate-path to support tweening varying # of points

* internal(xychart/useScales): add domain data validation checks

* fix(xychart/AnimatedBars): animate from scale baseline, not 0

* new(demo/xychart): add better missing values, and control padding

* new(xychart/AnimatedGlyphs): animate opacity

* fix(xychart): add better data validation to BaseBarStack, BaseBarGroup, BaseBarSeries

* types(xychart): fix

* test(xychart): remove horizontal props

* types(xychart): add horizontal to mock data context

* internal(xychart/useScales): don't unnecessarily filter values for d3 extent

* lint(xychart/useScales)
  • Loading branch information
williaster authored Nov 3, 2020
1 parent 490045d commit 68648e2
Show file tree
Hide file tree
Showing 21 changed files with 211 additions and 108 deletions.
13 changes: 4 additions & 9 deletions packages/visx-demo/src/sandboxes/visx-xychart/Example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default function Example({ height }: Props) {
numTicks={numTicks}
/>
{renderBarStack && (
<AnimatedBarStack horizontal={renderHorizontally}>
<AnimatedBarStack>
<AnimatedBarSeries
dataKey="New York"
data={data}
Expand All @@ -85,7 +85,7 @@ export default function Example({ height }: Props) {
</AnimatedBarStack>
)}
{renderBarGroup && (
<AnimatedBarGroup horizontal={renderHorizontally}>
<AnimatedBarGroup>
<AnimatedBarSeries
dataKey="New York"
data={data}
Expand All @@ -112,7 +112,6 @@ export default function Example({ height }: Props) {
data={data}
xAccessor={accessors.x['New York']}
yAccessor={accessors.y['New York']}
horizontal={renderHorizontally}
/>
)}
{renderAreaSeries && (
Expand All @@ -122,16 +121,14 @@ export default function Example({ height }: Props) {
data={data}
xAccessor={accessors.x.Austin}
yAccessor={accessors.y.Austin}
horizontal={renderHorizontally}
fillOpacity={0.3}
fillOpacity={0.5}
/>
<AnimatedAreaSeries
dataKey="San Francisco"
data={data}
xAccessor={accessors.x['San Francisco']}
yAccessor={accessors.y['San Francisco']}
horizontal={renderHorizontally}
fillOpacity={0.3}
fillOpacity={0.5}
/>
</>
)}
Expand All @@ -142,14 +139,12 @@ export default function Example({ height }: Props) {
data={data}
xAccessor={accessors.x.Austin}
yAccessor={accessors.y.Austin}
horizontal={renderHorizontally}
/>
<AnimatedLineSeries
dataKey="San Francisco"
data={data}
xAccessor={accessors.x['San Francisco']}
yAccessor={accessors.y['San Francisco']}
horizontal={renderHorizontally}
/>
</>
)}
Expand Down
42 changes: 38 additions & 4 deletions packages/visx-demo/src/sandboxes/visx-xychart/ExampleControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const dateScaleConfig = { type: 'band', paddingInner: 0.3 } as const;
const temperatureScaleConfig = { type: 'linear' } as const;
const numTicks = 4;
const data = cityTemperature.slice(225, 275);
const dataMissingValues = data.map((d, i) =>
i === 10 || i === 11
? { ...d, 'San Francisco': 'nope', 'New York': 'notanumber', Austin: 'null' }
: d,
);
const dataSmall = data.slice(0, 15);
const dataSmallMissingValues = dataMissingValues.slice(0, 15);
const getDate = (d: CityTemperature) => d.date;
const getSfTemperature = (d: CityTemperature) => Number(d['San Francisco']);
const getNegativeSfTemperature = (d: CityTemperature) => -getSfTemperature(d);
Expand Down Expand Up @@ -82,12 +89,14 @@ export default function ExampleControls({ children }: ControlsProps) {
const [sharedTooltip, setSharedTooltip] = useState(true);
const [renderBarStackOrGroup, setRenderBarStackOrGroup] = useState<
'bar' | 'stack' | 'group' | 'none'
>('none');
>('bar');
const [renderLineOrAreaSeries, setRenderLineOrAreaSeries] = useState<'line' | 'area' | 'none'>(
'line',
);
const [renderGlyphSeries, setRenderGlyphSeries] = useState(true);
const [renderGlyphSeries, setRenderGlyphSeries] = useState(false);
const [negativeValues, setNegativeValues] = useState(false);
const [fewerDatum, setFewerDatum] = useState(false);
const [missingValues, setMissingValues] = useState(false);
const [glyphComponent, setGlyphComponent] = useState<'star' | 'cross' | 'circle' | '🍍'>('star');
const themeBackground = theme.backgroundColor;
const renderGlyph = useCallback(
Expand Down Expand Up @@ -151,7 +160,13 @@ export default function ExampleControls({ children }: ControlsProps) {
accessors,
animationTrajectory,
config,
data,
data: fewerDatum
? missingValues
? dataSmallMissingValues
: dataSmall
: missingValues
? dataMissingValues
: data,
numTicks,
renderBarGroup: renderBarStackOrGroup === 'group',
renderBarSeries: renderBarStackOrGroup === 'bar',
Expand Down Expand Up @@ -517,15 +532,34 @@ export default function ExampleControls({ children }: ControlsProps) {
/>
negative values (SF)
</label>
<label>
<input
type="checkbox"
onChange={() => setMissingValues(!missingValues)}
checked={missingValues}
/>
missing values
</label>
<label>
<input
type="checkbox"
onChange={() => setFewerDatum(!fewerDatum)}
checked={fewerDatum}
/>
fewer datum
</label>
</div>
</div>
<style jsx>{`
.controls {
font-size: 13px;
line-height: 1.5em;
}
.controls > div {
margin-bottom: 8px;
}
label {
font-size: 11px;
font-size: 12px;
}
input[type='radio'] {
height: 10px;
Expand Down
1 change: 1 addition & 0 deletions packages/visx-xychart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@visx/voronoi": "1.0.0",
"classnames": "^2.2.5",
"d3-array": "^2.6.0",
"d3-interpolate-path": "2.2.1",
"d3-shape": "^2.0.0",
"lodash": "^4.17.10",
"mitt": "^2.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { AxisScale } from '@visx/axis';
import { coerceNumber } from '@visx/scale';
import React, { useMemo } from 'react';
import { animated, useTransition } from 'react-spring';
import { Bar, BarsProps } from '../../../types';
import getScaleBaseline from '../../../utils/getScaleBaseline';

function enterUpdate({ x, y, width, height, fill }: Bar) {
return {
Expand All @@ -26,14 +26,12 @@ function useBarTransitionConfig<Scale extends AxisScale>({
}: BarTransitionConfig<Scale>) {
const shouldAnimateX = !!horizontal;
return useMemo(() => {
const [a, b] = scale.range().map(coerceNumber);
const isDescending = b != null && a != null && b < a;
const [scaleMin, scaleMax] = isDescending ? [b, a] : [a, b];
const scaleBaseline = getScaleBaseline(scale);

function fromLeave({ x, y, width, height, fill }: Bar) {
return {
x: shouldAnimateX ? scaleMin ?? 0 : x,
y: shouldAnimateX ? y : scaleMax ?? 0,
x: shouldAnimateX ? scaleBaseline ?? 0 : x,
y: shouldAnimateX ? y : scaleBaseline ?? 0,
width: shouldAnimateX ? 0 : width,
height: shouldAnimateX ? height : 0,
fill,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ export default function AnimatedGlyphs<
<>
{animatedGlyphs.map((
// @ts-ignore x/y aren't in react-spring's CSSProperties
{ item, key, props: { x, y, color } },
{ item, key, props: { x, y, color, opacity } },
) => (
<animated.g
key={key}
transform={interpolate([x, y], (xVal, yVal) => `translate(${xVal}, ${yVal})`)}
color={color}
opacity={opacity}
>
{renderGlyph({
key,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
import React from 'react';
import React, { useRef } from 'react';
import { animated, useSpring } from 'react-spring';
// @ts-ignore no types
import { interpolatePath } from 'd3-interpolate-path';

export default function AnimatedPath({
d,
stroke,
fill,
...lineProps
}: Omit<React.SVGProps<SVGPathElement>, 'ref'>) {
const tweened = useSpring({ d, stroke, fill });
return <animated.path d={tweened.d} stroke={tweened.stroke} fill={tweened.fill} {...lineProps} />;
const previousD = useRef(d);
// react-spring cannot interpolate paths which have a differing number of points
// flubber is the "best" at interpolating but assumes closed paths
// d3-interpolate-path is better at interpolating extra/fewer points so we use that
const interpolator = interpolatePath(previousD.current, d);
previousD.current = d;
// @ts-ignore t is not in CSSProperties
const { t } = useSpring({
from: { t: 0 },
to: { t: 1 },
reset: true,
});
const tweened = useSpring({ stroke, fill });
return (
<animated.path
d={t.interpolate(interpolator)}
stroke={tweened.stroke}
fill={tweened.fill}
{...lineProps}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@ import findNearestDatumX from '../../../utils/findNearestDatumX';
import TooltipContext from '../../../context/TooltipContext';
import findNearestDatumY from '../../../utils/findNearestDatumY';
import getScaleBaseline from '../../../utils/getScaleBaseline';
import isValidNumber from '../../../typeguards/isValidNumber';

export type BaseAreaSeriesProps<
XScale extends AxisScale,
YScale extends AxisScale,
Datum extends object
> = SeriesProps<XScale, YScale, Datum> & {
/** Whether area should be rendered horizontally instead of vertically. */
horizontal?: boolean;
/** Whether to render a Line on top of the Area shape (fill only). */
/** Whether to render a Line along value of the Area shape (area is fill only). */
renderLine?: boolean;
/** Props to be passed to the Line, if rendered. */
lineProps?: Omit<LinePathProps<Datum>, 'data' | 'x' | 'y' | 'children' | 'defined'>;
Expand All @@ -30,7 +29,6 @@ export type BaseAreaSeriesProps<
function BaseAreaSeries<XScale extends AxisScale, YScale extends AxisScale, Datum extends object>({
data,
dataKey,
horizontal,
xAccessor,
xScale,
yAccessor,
Expand All @@ -40,10 +38,14 @@ function BaseAreaSeries<XScale extends AxisScale, YScale extends AxisScale, Datu
lineProps,
...areaProps
}: BaseAreaSeriesProps<XScale, YScale, Datum> & WithRegisteredDataProps<XScale, YScale, Datum>) {
const { colorScale, theme, width, height } = useContext(DataContext);
const { colorScale, theme, width, height, horizontal } = useContext(DataContext);
const { showTooltip, hideTooltip } = useContext(TooltipContext) ?? {};
const getScaledX = useCallback(getScaledValueFactory(xScale, xAccessor), [xScale, xAccessor]);
const getScaledY = useCallback(getScaledValueFactory(yScale, yAccessor), [yScale, yAccessor]);
const isDefined = useCallback(
(d: Datum) => isValidNumber(xScale(xAccessor(d))) && isValidNumber(yScale(yAccessor(d))),
[xScale, xAccessor, yScale, yAccessor],
);
const color = colorScale?.(dataKey) ?? theme?.colors?.[0] ?? '#222';

const handleMouseMove = useCallback(
Expand Down Expand Up @@ -95,18 +97,18 @@ function BaseAreaSeries<XScale extends AxisScale, YScale extends AxisScale, Datu

return (
<>
<Area data={data} {...xAccessors} {...yAccessors} {...areaProps}>
<Area {...xAccessors} {...yAccessors} {...areaProps} defined={isDefined}>
{({ path }) => (
<PathComponent stroke="transparent" fill={color} {...areaProps} d={path(data) || ''} />
)}
</Area>
{renderLine && (
<LinePath<Datum>
data={data}
x={getScaledX}
y={getScaledY}
stroke={color}
strokeWidth={2}
defined={isDefined}
{...lineProps}
>
{({ path }) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@ import { PositionScale } from '@visx/shape/lib/types';
import { scaleBand } from '@visx/scale';
import isChildWithProps from '../../../typeguards/isChildWithProps';
import { BaseBarSeriesProps } from './BaseBarSeries';
import { BarsProps, DataContextType } from '../../../types';
import { Bar, BarsProps, DataContextType } from '../../../types';
import DataContext from '../../../context/DataContext';
import getScaleBandwidth from '../../../utils/getScaleBandwidth';
import findNearestDatumY from '../../../utils/findNearestDatumY';
import findNearestDatumX from '../../../utils/findNearestDatumX';
import useEventEmitter, { HandlerParams } from '../../../hooks/useEventEmitter';
import TooltipContext from '../../../context/TooltipContext';
import getScaleBaseline from '../../../utils/getScaleBaseline';
import isValidNumber from '../../../typeguards/isValidNumber';

export type BaseBarGroupProps<XScale extends PositionScale, YScale extends PositionScale> = {
/** Whether to render the Stack horizontally instead of vertically. */
horizontal?: boolean;
/** `BarSeries` elements */
children: JSX.Element | JSX.Element[];
/** Group band scale padding, [0, 1] where 0 = no padding, 1 = no bar. */
Expand All @@ -29,13 +28,7 @@ export default function BaseBarGroup<
XScale extends PositionScale,
YScale extends PositionScale,
Datum extends object
>({
children,
horizontal,
padding = 0.1,
sortBars,
BarsComponent,
}: BaseBarGroupProps<XScale, YScale>) {
>({ children, padding = 0.1, sortBars, BarsComponent }: BaseBarGroupProps<XScale, YScale>) {
const {
xScale,
yScale,
Expand All @@ -45,6 +38,7 @@ export default function BaseBarGroup<
unregisterData,
width,
height,
horizontal,
} = (useContext(DataContext) as unknown) as DataContextType<XScale, YScale, Datum>;

const barSeriesChildren = useMemo(
Expand Down Expand Up @@ -132,8 +126,8 @@ export default function BaseBarGroup<
const bars = registryEntries.flatMap(({ xAccessor, yAccessor, data, key }) => {
const getLength = (d: Datum) =>
horizontal
? (xScale(xAccessor(d)) ?? 0) - xZeroPosition
: (yScale(yAccessor(d)) ?? 0) - yZeroPosition;
? (xScale(xAccessor(d)) ?? NaN) - xZeroPosition
: (yScale(yAccessor(d)) ?? NaN) - yZeroPosition;

const getGroupPosition = horizontal
? (d: Datum) => yScale(yAccessor(d)) ?? 0
Expand All @@ -152,14 +146,27 @@ export default function BaseBarGroup<
const getWidth = horizontal ? (d: Datum) => Math.abs(getLength(d)) : () => barThickness;
const getHeight = horizontal ? () => barThickness : (d: Datum) => Math.abs(getLength(d));

return data.map((datum, index) => ({
key: `${key}-${index}`,
x: getX(datum),
y: getY(datum),
width: getWidth(datum),
height: getHeight(datum),
fill: colorScale(key),
}));
return data
.map((bar, index) => {
const barX = getX(bar);
if (!isValidNumber(barX)) return null;
const barY = getY(bar);
if (!isValidNumber(barY)) return null;
const barWidth = getWidth(bar);
if (!isValidNumber(barWidth)) return null;
const barHeight = getHeight(bar);
if (!isValidNumber(barHeight)) return null;

return {
key: `${key}-${index}`,
x: barX,
y: barY,
width: barWidth,
height: barHeight,
fill: colorScale(key),
};
})
.filter(bar => bar) as Bar[];
});

return (
Expand Down
Loading

0 comments on commit 68648e2

Please sign in to comment.