Skip to content

Commit

Permalink
new(xychart): add BarSeries (airbnb#808)
Browse files Browse the repository at this point in the history
* new(xychart): add withRegisteredData, pull out SeriesProps

* internal(xychart): factor out logic from LineSeries, fix Datum types

* new(xychart): iterate on BarSeries + add to example

* fix(xychart): rewrite useDataRegistry to update properly with hooks

* new(demo/xychart): add series rendering controls

* test(xychart): add useDataRegistry, withRegisteredData, BarSeries tests

* deps(xychart): remove uuid dep

* fix(xychart): fix withRegisteredData, improve BarSeries logic

* fix(withRegisteredData): one more round on generics

* fix(withRegisteredData): two more rounds on generics

* fix(withRegisteredData): remove unused test var

* internal(xychart/useDataRegistry): use single memo

* lint(xychart/useDataRegistry)
  • Loading branch information
williaster authored Oct 1, 2020
1 parent b036a23 commit 5813f1c
Show file tree
Hide file tree
Showing 26 changed files with 558 additions and 128 deletions.
76 changes: 51 additions & 25 deletions packages/visx-demo/src/sandboxes/visx-xychart/Example.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import React from 'react';
import cityTemperature, { CityTemperature } from '@visx/mock-data/lib/mocks/cityTemperature';
import { AnimatedAxis, AnimatedGrid, DataProvider, LineSeries, XYChart } from '@visx/xychart';
import {
AnimatedAxis,
AnimatedGrid,
DataProvider,
BarSeries,
LineSeries,
XYChart,
} from '@visx/xychart';
import ExampleControls from './ExampleControls';
import CustomChartBackground from './CustomChartBackground';

Expand All @@ -9,53 +16,72 @@ type Props = {
height: number;
};

const xScaleConfig = { type: 'time' } as const;
const xScaleConfig = { type: 'band', paddingInner: 0.3 } as const;
const yScaleConfig = { type: 'linear' } as const;
const numTicks = 4;
const data = cityTemperature.slice(0, 100);
const getDate = (d: CityTemperature) => new Date(d.date);
const data = cityTemperature.slice(150, 225);
const getDate = (d: CityTemperature) => d.date; // new Date(d.date);
const getSfTemperature = (d: CityTemperature) => Number(d['San Francisco']);
const getNyTemperature = (d: CityTemperature) => Number(d['New York']);

export default function Example({ height }: Props) {
return (
<ExampleControls>
{({
animationTrajectory,
renderBarSeries,
renderHorizontally,
renderLineSeries,
showGridColumns,
showGridRows,
theme,
xAxisOrientation,
yAxisOrientation,
showGridRows,
showGridColumns,
animationTrajectory,
}) => (
<DataProvider theme={theme} xScale={xScaleConfig} yScale={yScaleConfig}>
<DataProvider
theme={theme}
xScale={renderHorizontally ? yScaleConfig : xScaleConfig}
yScale={renderHorizontally ? xScaleConfig : yScaleConfig}
>
<XYChart height={Math.min(400, height)}>
<CustomChartBackground />
<AnimatedGrid
key={`grid-${animationTrajectory}`} // force animate on update
rows={showGridRows}
columns={showGridColumns}
animationTrajectory={animationTrajectory}
numTicks={numTicks}
/>
{renderBarSeries && (
<BarSeries
dataKey="ny"
data={data}
xAccessor={renderHorizontally ? getNyTemperature : getDate}
yAccessor={renderHorizontally ? getDate : getNyTemperature}
horizontal={renderHorizontally}
/>
)}
{renderLineSeries && (
<LineSeries
dataKey="sf"
data={data}
xAccessor={renderHorizontally ? getSfTemperature : getDate}
yAccessor={renderHorizontally ? getDate : getSfTemperature}
/>
)}
<AnimatedAxis
key={`xaxis-${animationTrajectory}`} // force animate on update
orientation={xAxisOrientation}
key={`time-axis-${animationTrajectory}-${renderHorizontally}`}
orientation={renderHorizontally ? yAxisOrientation : xAxisOrientation}
numTicks={numTicks}
animationTrajectory={animationTrajectory}
/>
<AnimatedAxis
key={`yaxis-${animationTrajectory}`}
key={`temp-axis-${animationTrajectory}-${renderHorizontally}`}
label="Temperature (°F)"
orientation={yAxisOrientation}
orientation={renderHorizontally ? xAxisOrientation : yAxisOrientation}
numTicks={numTicks}
animationTrajectory={animationTrajectory}
/>
<AnimatedGrid
key={`grid-${animationTrajectory}`}
rows={showGridRows}
columns={showGridColumns}
animationTrajectory={animationTrajectory}
numTicks={numTicks}
/>
<LineSeries
dataKey="line"
data={data}
xAccessor={getDate}
yAccessor={getSfTemperature}
/>
</XYChart>
</DataProvider>
)}
Expand Down
50 changes: 50 additions & 0 deletions packages/visx-demo/src/sandboxes/visx-xychart/ExampleControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import customTheme from './customTheme';

type ProvidedProps = {
animationTrajectory: AnimationTrajectory;
renderHorizontally: boolean;
renderBarSeries: boolean;
renderLineSeries: boolean;
showGridColumns: boolean;
showGridRows: boolean;
theme: XYChartTheme;
Expand All @@ -24,11 +27,17 @@ export default function ExampleControls({ children }: ControlsProps) {
const [showGridRows, showGridColumns] = gridProps;
const [xAxisOrientation, setXAxisOrientation] = useState<'top' | 'bottom'>('bottom');
const [yAxisOrientation, setYAxisOrientation] = useState<'left' | 'right'>('right');
const [renderHorizontally, setRenderHorizontally] = useState(false);
const [renderBarSeries, setRenderBarSeries] = useState(true);
const [renderLineSeries, setRenderLineSeries] = useState(true);

return (
<>
{children({
animationTrajectory,
renderBarSeries,
renderHorizontally,
renderLineSeries,
showGridColumns,
showGridRows,
theme,
Expand Down Expand Up @@ -65,6 +74,27 @@ export default function ExampleControls({ children }: ControlsProps) {
</label>
</div>

{/** series orientation */}
<div>
<strong>series orientation</strong>
<label>
<input
type="radio"
onChange={() => setRenderHorizontally(false)}
checked={!renderHorizontally}
/>{' '}
vertical
</label>
<label>
<input
type="radio"
onChange={() => setRenderHorizontally(true)}
checked={renderHorizontally}
/>{' '}
horizontal
</label>
</div>

{/** axes */}
<div>
<strong>axes</strong>
Expand Down Expand Up @@ -175,6 +205,26 @@ export default function ExampleControls({ children }: ControlsProps) {
from max
</label>
</div>
{/** series */}
<div>
<strong>series</strong>
<label>
<input
type="checkbox"
onChange={() => setRenderLineSeries(!renderLineSeries)}
checked={renderLineSeries}
/>{' '}
line
</label>
<label>
<input
type="checkbox"
onChange={() => setRenderBarSeries(!renderBarSeries)}
checked={renderBarSeries}
/>{' '}
bar
</label>
</div>
</div>
<style jsx>{`
.controls {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { buildChartTheme } from '@visx/xychart';

export default buildChartTheme({
backgroundColor: '#f09ae9',
colors: ['#6a097d', '#c060a1', '#ffc1fa'],
colors: ['rgba(255,231,143,0.8)', '#6a097d', '#ffc1fa'],
gridColor: '#336d88',
gridColorDark: '#1d1b38',
labelStyles: { fill: '#1d1b38' },
Expand Down
6 changes: 3 additions & 3 deletions packages/visx-react-spring/src/axis/AnimatedTicks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function AnimatedTicks<Scale extends AxisScale>({
ticks,
animationTrajectory,
}: TicksRendererProps<Scale> & { animationTrajectory?: AnimationTrajectory }) {
const animatedTicks = useTransition(ticks, tick => `${tick.value}-${horizontal}`, {
const animatedTicks = useTransition(ticks, tick => `${tick.value}`, {
unique: true,
...useLineTransitionConfig({ scale, animateXOrY: horizontal ? 'x' : 'y', animationTrajectory }),
});
Expand All @@ -39,7 +39,7 @@ export default function AnimatedTicks<Scale extends AxisScale>({
index,
) => {
const tickLabelProps = allTickLabelProps[index] ?? allTickLabelProps[0] ?? {};
return (
return item == null || key == null ? null : (
<animated.g
key={key}
className={cx('visx-axis-tick', tickClassName)}
Expand Down Expand Up @@ -70,7 +70,7 @@ export default function AnimatedTicks<Scale extends AxisScale>({
)}
opacity={opacity}
>
<Text {...tickLabelProps}>{item.formattedValue}</Text>
<Text {...tickLabelProps}>{item?.formattedValue}</Text>
</animated.g>
</animated.g>
);
Expand Down
7 changes: 3 additions & 4 deletions packages/visx-xychart/src/classes/DataRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { DataRegistryEntry } from '../types/data';
export default class DataRegistry<
XScale extends AxisScale,
YScale extends AxisScale,
Datum = unknown
Datum extends object
> {
private registry: { [key: string]: DataRegistryEntry<XScale, YScale, Datum> } = {};

Expand All @@ -21,10 +21,9 @@ export default class DataRegistry<
entries.forEach(currEntry => {
if (currEntry.key in this.registry && this.registry[currEntry.key] != null) {
console.debug('Overriding data registry key', currEntry.key);
this.registryKeys = this.registryKeys.filter(key => key !== currEntry.key);
}
this.registry[currEntry.key] = currEntry;
this.registryKeys.push(currEntry.key);
this.registryKeys = Object.keys(this.registry);
});
}

Expand All @@ -33,7 +32,7 @@ export default class DataRegistry<
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
keys.forEach(currKey => {
delete this.registry[currKey];
this.registryKeys = this.registryKeys.filter(key => key !== currKey);
this.registryKeys = Object.keys(this.registry);
});
}

Expand Down
92 changes: 92 additions & 0 deletions packages/visx-xychart/src/components/series/BarSeries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React, { useContext, useCallback, useMemo } from 'react';
import { AxisScale } from '@visx/axis';
import DataContext from '../../context/DataContext';
import { SeriesProps } from '../../types';
import withRegisteredData, { WithRegisteredDataProps } from '../../enhancers/withRegisteredData';
import getScaledValueFactory from '../../utils/getScaledValueFactory';
import isValidNumber from '../../typeguards/isValidNumber';
import getScaleBandwidth from '../../utils/getScaleBandwidth';

type BarSeriesProps<
XScale extends AxisScale,
YScale extends AxisScale,
Datum extends object
> = SeriesProps<XScale, YScale, Datum> & {
/** Whether bars should be rendered horizontally instead of vertically. */
horizontal?: boolean;
/**
* Specify bar padding when bar thickness does not come from a `band` scale.
* Accepted values are [0, 1], 0 = no padding, 1 = no bar, defaults to 0.1.
*/
barPadding?: number;
};

// Fallback bandwidth estimate assumes no missing data values (divides chart space by # datum)
const getFallbackBandwidth = (fullBarWidth: number, barPadding: number) =>
// clamp padding to [0, 1], bar thickness = (1-padding) * availableSpace
fullBarWidth * (1 - Math.min(1, Math.max(0, barPadding)));

function BarSeries<XScale extends AxisScale, YScale extends AxisScale, Datum extends object>({
barPadding = 0.1,
data,
dataKey,
horizontal,
xAccessor,
xScale,
yAccessor,
yScale,
}: BarSeriesProps<XScale, YScale, Datum> & WithRegisteredDataProps<XScale, YScale, Datum>) {
const { colorScale, theme, innerWidth = 0, innerHeight = 0 } = useContext(DataContext);
const getScaledX = useCallback(getScaledValueFactory(xScale, xAccessor), [xScale, xAccessor]);
const getScaledY = useCallback(getScaledValueFactory(yScale, yAccessor), [yScale, yAccessor]);
const [xMin, xMax] = xScale.range().map(Number);
const [yMax, yMin] = yScale.range().map(Number);

const scaleBandwidth = getScaleBandwidth(horizontal ? yScale : xScale);
const barThickness =
scaleBandwidth ||
getFallbackBandwidth((horizontal ? innerHeight : innerWidth) / data.length, barPadding);

// try to figure out the 0 baseline for correct rendering of negative values
// we aren't sure if these are numeric scales or not ahead of time
const maybeXZero = xScale(0);
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))
: Math.min(xMin, xMax);
const yZeroPosition = isValidNumber(maybeYZero)
? Math.min(maybeYZero, Math.max(yMin, yMax))
: Math.max(yMin, yMax);

const color = colorScale?.(dataKey) ?? theme?.colors?.[0] ?? '#222';

const bars = useMemo(() => {
const xOffset = horizontal ? 0 : -barThickness / 2;
const yOffset = horizontal ? -barThickness / 2 : 0;
return data.map(datum => {
const x = getScaledX(datum) + xOffset;
const y = getScaledY(datum) + yOffset;
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),
fill: color, // @TODO allow prop overriding
};
});
}, [barThickness, color, data, getScaledX, getScaledY, horizontal, xZeroPosition, yZeroPosition]);

return (
<g className="vx-bar-series">
{bars.map(({ x, y, width, height, fill }, i) => (
<rect key={i} x={x} y={y} width={width} height={height} fill={fill} />
))}
</g>
);
}

export default withRegisteredData(BarSeries);
Loading

0 comments on commit 5813f1c

Please sign in to comment.