Skip to content

Commit

Permalink
Update StackedAreaChart to use tooltip portal
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelnesen committed Jun 18, 2024
1 parent 131de48 commit 7f33c5e
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 32 deletions.
6 changes: 5 additions & 1 deletion packages/polaris-viz/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

<!-- ## Unreleased -->
## Unreleased

### Changed

- Changed `<StackedAreaChart />` to use a react portal to allow tooltips to render outside the bounds of the chart.

## [13.2.0] - 2024-05-27

Expand Down
31 changes: 21 additions & 10 deletions packages/polaris-viz/src/components/StackedAreaChart/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type {
DataPoint,
XAxisOptions,
YAxisOptions,
Dimensions,
BoundingRect,
LabelFormatter,
} from '@shopify/polaris-viz-core';
Expand All @@ -18,6 +17,7 @@ import {
COLOR_VISION_SINGLE_ITEM,
useChartPositions,
LINE_HEIGHT,
clamp,
} from '@shopify/polaris-viz-core';

import {ChartElements} from '../ChartElements';
Expand Down Expand Up @@ -80,7 +80,7 @@ export interface Props {
theme: string;
xAxisOptions: Required<XAxisOptions>;
yAxisOptions: Required<YAxisOptions>;
dimensions?: Dimensions;
dimensions?: BoundingRect;
renderLegendContent?: RenderLegendContent;
renderHiddenLegendLabel?: (count: number) => string;
}
Expand Down Expand Up @@ -127,6 +127,7 @@ export function Chart({

const {
stackedValues,
longestSeriesIndex,
longestSeriesLength,
labels: formattedLabels,
} = useStackedData({
Expand Down Expand Up @@ -256,8 +257,8 @@ export function Chart({
const chartBounds: BoundingRect = {
width,
height,
x: chartXPosition,
y: chartYPosition,
x: dimensions?.x ?? chartXPosition,
y: dimensions?.y ?? chartYPosition,
};

const {hasXAxisAnnotations, hasYAxisAnnotations} = checkAvailableAnnotations(
Expand Down Expand Up @@ -395,6 +396,7 @@ export function Chart({
margin={ChartMargin}
onIndexChange={(index) => setActivePointIndex(index)}
parentRef={svgRef}
usePortal
/>
)}

Expand Down Expand Up @@ -424,20 +426,29 @@ export function Chart({
return TOOLTIP_POSITION_DEFAULT_RETURN;
}

const {svgX, svgY} = point;
const {svgX} = point;

const closestIndex = Math.round(xScale.invert(svgX - chartXPosition));

const activeIndex = clamp({
amount: closestIndex,
min: 0,
max: data[longestSeriesIndex].data.length - 1,
});

return {
x: svgX,
y: svgY,
x: (event as MouseEvent).pageX,
y: (event as MouseEvent).pageY,
position: TOOLTIP_POSITION,
activeIndex: Math.min(longestSeriesLength, closestIndex),
activeIndex,
};
} else if (index != null) {
const activeIndex = index ?? 0;
const x = xScale?.(activeIndex) ?? 0;

return {
x: xScale?.(index) ?? 0,
y: 0,
x: x + (dimensions?.x ?? 0),
y: dimensions?.y ?? 0,
position: TOOLTIP_POSITION,
activeIndex: index,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,20 @@ export function useStackedData({data, xAxisOptions}: Props) {
return Math.max(...stackedValues.map((stack) => stack.length)) - 1;
}, [stackedValues]);

return {labels: formattedLabels, longestSeriesLength, stackedValues};
const longestSeriesIndex = useMemo(
() =>
data.reduce((maxIndex, currentSeries, currentIndex) => {
return data[maxIndex].data.length < currentSeries.data.length
? currentIndex
: maxIndex;
}, 0),
[data],
);

return {
labels: formattedLabels,
longestSeriesIndex,
longestSeriesLength,
stackedValues,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type {Story} from '@storybook/react';

import type {StackedAreaChartProps} from '../../StackedAreaChart';
import {StackedAreaChart} from '../../StackedAreaChart';
import {META} from '../meta';
import {DEFAULT_DATA, DEFAULT_PROPS} from '../data';

export default {
...META,
title: `${META.title}/Playground`,
decorators: [],
};

function Card(args: StackedAreaChartProps) {
return (
<div
style={{
height: 400,
width: 400,
background: 'white',
borderRadius: '8px',
padding: 10,
}}
>
<StackedAreaChart {...args} theme="Uplift" />
</div>
);
}

const Template: Story<StackedAreaChartProps> = (
args: StackedAreaChartProps,
) => {
return (
<div style={{overflow: 'auto'}}>
<Card {...args} />
<div style={{height: 700, width: 10}} />
<div style={{display: 'flex', justifyContent: 'space-between'}}>
<Card {...args} />
<Card {...args} />
<Card {...args} />
</div>
</div>
);
};

export const ExternalTooltipPortal: Story<StackedAreaChartProps> =
Template.bind({});

ExternalTooltipPortal.args = {
...DEFAULT_PROPS,
data: DEFAULT_DATA,
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type {Dimensions} from '@shopify/polaris-viz-core';
import {clamp} from '@shopify/polaris-viz-core';

import {getRightPosition} from '../../../components/TooltipWrapper';
import type {TooltipPositionOffset} from '../../TooltipWrapper';
import type {Margin} from '../../../types';

// The space between the cursor and the tooltip
const TOOLTIP_MARGIN = 20;
const SCROLLBAR_WIDTH = 20;

export interface AlteredPositionProps {
bandwidth: number;
Expand All @@ -26,29 +29,59 @@ export type AlteredPosition = (
props: AlteredPositionProps,
) => AlteredPositionReturn;

export function getAlteredStackedAreaChartPosition({
currentX,
currentY,
chartBounds,
margin,
tooltipDimensions,
}: AlteredPositionProps): AlteredPositionReturn {
const x = Math.min(
Math.max(currentX, TOOLTIP_MARGIN),
chartBounds.width - tooltipDimensions.width - TOOLTIP_MARGIN,
);
export function getAlteredStackedAreaChartPosition(
props: AlteredPositionProps,
): AlteredPositionReturn {
const {currentX, currentY, chartBounds, margin, tooltipDimensions} = props;

let x = currentX;
let y = currentY;

// Y POSITIONING
// If y is below the chart, adjust the tooltip position to the bottom of the chart
//

const y =
y =
currentY >= chartBounds.y + chartBounds.height
? chartBounds.height -
tooltipDimensions.height -
TOOLTIP_MARGIN -
margin.Bottom
: currentY;

return {x, y};
// X POSITIONING
const right = getRightPosition(x, props);
x = right.value;

if (right.wasOutsideBounds) {
const left = getLeftPosition(x);
x = left.value;
}

return {
x: clamp({
amount: x,
min: TOOLTIP_MARGIN,
max:
window.innerWidth -
props.tooltipDimensions.width -
TOOLTIP_MARGIN -
SCROLLBAR_WIDTH,
}),
y: clamp({
amount: y,
min: window.scrollY + TOOLTIP_MARGIN,
max:
window.scrollY +
window.innerHeight -
props.tooltipDimensions.height -
TOOLTIP_MARGIN,
}),
};
}

function getLeftPosition(value: number): {
value: number;
wasOutsideBounds: boolean;
} {
return {value: value - TOOLTIP_MARGIN, wasOutsideBounds: false};
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,25 @@ const BASE_PROPS: AlteredPositionProps = {
},
};

let windowSpy;

function mockWindow({scrollY = 0, innerHeight = 1000, innerWidth = 500}) {
windowSpy.mockImplementation(() => ({
scrollY,
innerHeight,
innerWidth,
}));
}

describe('getAlteredStackedAreaChartPosition', () => {
beforeEach(() => {
windowSpy = jest.spyOn(window, 'window', 'get');
});

afterEach(() => {
windowSpy.mockRestore();
});

it('returns the original position of y when currentY is within the chart bounds', () => {
const props = {
...BASE_PROPS,
Expand All @@ -32,7 +50,6 @@ describe('getAlteredStackedAreaChartPosition', () => {

const result = getAlteredStackedAreaChartPosition(props);

expect(result.x).toBe(50);
expect(result.y).toBe(50);
});

Expand All @@ -47,8 +64,6 @@ describe('getAlteredStackedAreaChartPosition', () => {
const margin = props.margin;
const result = getAlteredStackedAreaChartPosition(props);

expect(result.x).toBe(20);

expect(result.y).toBe(
chartBounds.height -
tooltipDimensions.height -
Expand All @@ -68,13 +83,41 @@ describe('getAlteredStackedAreaChartPosition', () => {
const margin = props.margin;
const result = getAlteredStackedAreaChartPosition(props);

expect(result.x).toBe(20);

expect(result.y).toBe(
chartBounds.height -
tooltipDimensions.height -
TOOLTIP_MARGIN -
margin.Bottom,
);
});

describe('x', () => {
it('clamps to the left of a window', () => {
mockWindow({
scrollY: 0,
});

expect(
getAlteredStackedAreaChartPosition({
...BASE_PROPS,
currentX: -1000,
currentY: 0,
}),
).toStrictEqual({x: 20, y: 20});
});

it('clamps to the right of a window', () => {
mockWindow({
scrollY: 0,
});

expect(
getAlteredStackedAreaChartPosition({
...BASE_PROPS,
currentX: 1000,
currentY: 0,
}),
).toStrictEqual({x: 400, y: 20});
});
});
});
6 changes: 5 additions & 1 deletion packages/polaris-viz/src/components/TooltipWrapper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ export type {
TooltipPositionOffset,
} from './types';
export {TooltipHorizontalOffset, TooltipVerticalOffset} from './types';
export {getAlteredVerticalBarPosition, TOOLTIP_MARGIN} from './utilities';
export {
getAlteredVerticalBarPosition,
getRightPosition,
TOOLTIP_MARGIN,
} from './utilities';
export type {
AlteredPositionProps,
AlteredPositionReturn,
Expand Down

0 comments on commit 7f33c5e

Please sign in to comment.