Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bar dimension selection ring #419

Merged
merged 3 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/specBuilder/bar/barSpecBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
Data,
GroupMark,
Mark,
RectMark,
Scale,
ScaleData,
SourceData,
Expand Down Expand Up @@ -428,6 +429,18 @@ describe('barSpecBuilder', () => {
expect((addedMarks[0] as GroupMark).marks).toHaveLength(3);
});

test('should add dimension selection ring if a popover is highlighting by dimension', () => {
const marks = addMarks([], {
...defaultBarProps,
children: [createElement(ChartPopover, { UNSAFE_highlightBy: 'dimension' })],
});
expect(marks).toHaveLength(3);

const selectionRingMark = marks[2] as RectMark;
expect(selectionRingMark.type).toEqual('rect');
expect(selectionRingMark.name).toEqual('bar0_selectionRing');
});

test('should add trellis group mark if trellis prop is set', () => {
const marks = addMarks([], { ...defaultBarProps, trellis: 'trellis' });
expect(marks).toHaveLength(1);
Expand Down
9 changes: 7 additions & 2 deletions src/specBuilder/bar/barSpecBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
STACK_ID,
TRELLIS_PADDING,
} from '@constants';
import { addPopoverData } from '@specBuilder/chartPopover/chartPopoverUtils';
import { addPopoverData, getPopovers } from '@specBuilder/chartPopover/chartPopoverUtils';
import { addTooltipData, addTooltipSignals } from '@specBuilder/chartTooltip/chartTooltipUtils';
import { getTransformSort } from '@specBuilder/data/dataUtils';
import { getTooltipProps } from '@specBuilder/marks/markUtils';
Expand All @@ -41,7 +41,7 @@ import { produce } from 'immer';
import { BandScale, Data, FormulaTransform, Mark, OrdinalScale, Scale, Signal, Spec } from 'vega';

import { BarProps, BarSpecProps, ColorScheme } from '../../types';
import { getBarPadding, getScaleValues, isDodgedAndStacked } from './barUtils';
import { getBarPadding, getDimensionSelectionRing, getScaleValues, isDodgedAndStacked } from './barUtils';
import { getDodgedMark } from './dodgedBarUtils';
import { getDodgedAndStackedBarMark, getStackedBarMarks } from './stackedBarUtils';
import { addTrellisScale, getTrellisGroupMark, isTrellised } from './trellisedBarUtils';
Expand Down Expand Up @@ -259,6 +259,11 @@ export const addMarks = produce<Mark[], [BarSpecProps]>((marks, props) => {
barMarks.push(getDodgedMark(props));
}

const popovers = getPopovers(props);
if (popovers.some((popover) => popover.UNSAFE_highlightBy === 'dimension')) {
barMarks.push(getDimensionSelectionRing(props));
}

// if this is a trellis plot, we add the bars and the repeated scale to the trellis group
if (isTrellised(props)) {
const repeatedScale = getRepeatedScale(props);
Expand Down
69 changes: 69 additions & 0 deletions src/specBuilder/bar/barUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
getBarPadding,
getBaseBarEnterEncodings,
getCornerRadiusEncodings,
getDimensionSelectionRing,
getDodgedDimensionEncodings,
getDodgedGroupMark,
getMetricEncodings,
Expand Down Expand Up @@ -327,6 +328,66 @@ describe('barUtils', () => {
});
});

describe('getDimensionSelectionRing()', () => {
const barProps: Partial<BarSpecProps> = {
name: 'bar0',
colorScheme: 'light',
orientation: 'vertical',
paddingRatio: 0.3,
};

test('should return vertical selection ring', () => {
const selectionRing = getDimensionSelectionRing(barProps as BarSpecProps);
expect(selectionRing).toStrictEqual({
encode: {
enter: {
cornerRadius: { value: 6 },
fill: { value: 'transparent' },
stroke: { value: 'rgb(20, 115, 230)' },
strokeWidth: { value: 2 },
},
update: {
width: { signal: "bandwidth('xBand')/(1 - 0.3 / 2)" },
xc: { signal: "scale('xBand', datum.bar0_selectedGroupId) + bandwidth('xBand')/2" },
y: { value: 0 },
y2: { signal: 'height' },
},
},
from: {
data: 'bar0_selectedData',
},
interactive: false,
name: 'bar0_selectionRing',
type: 'rect',
});
});
test('should return horizontal selection ring', () => {
const selectionRing = getDimensionSelectionRing({ ...barProps, orientation: 'horizontal' } as BarSpecProps);
expect(selectionRing).toStrictEqual({
encode: {
enter: {
cornerRadius: { value: 6 },
fill: { value: 'transparent' },
stroke: { value: 'rgb(20, 115, 230)' },
strokeWidth: { value: 2 },
},
update: {
x: { value: 0 },
x2: { signal: 'width' },
yc: { signal: `scale('yBand', datum.bar0_selectedGroupId) + bandwidth('yBand')/2` },
height: { signal: `bandwidth('yBand')/(1 - 0.3 / 2)` },
},
},
from: {
data: 'bar0_selectedData',
},
interactive: false,
name: 'bar0_selectionRing',
type: 'rect',
});
});
});

describe('getStrokeDash()', () => {
test('should return production rule with one item in array if there is not a popover', () => {
const strokeRule = getStrokeDash(defaultBarProps);
Expand All @@ -350,6 +411,14 @@ describe('barUtils', () => {
expect(strokeRule).toHaveLength(1);
expect(strokeRule[0]).toStrictEqual({ value: 0 });
});
test('should return production rule with one item in array if there is a popover that highlights by dimension', () => {
const strokeRule = getStrokeWidth({
...defaultBarProps,
children: [createElement(ChartPopover, { UNSAFE_highlightBy: 'dimension' })],
});
expect(strokeRule).toHaveLength(1);
expect(strokeRule[0]).toStrictEqual({ value: 0 });
});
test('should return rules for selected data if popover exists', () => {
const popover = createElement(ChartPopover);
const strokeRule = getStrokeWidth({ ...defaultBarProps, children: [popover] });
Expand Down
49 changes: 47 additions & 2 deletions src/specBuilder/bar/barUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SELECTED_ITEM,
STACK_ID,
} from '@constants';
import { getPopovers } from '@specBuilder/chartPopover/chartPopoverUtils';
import {
getColorProductionRule,
getCursor,
Expand All @@ -36,6 +37,7 @@ import {
NumericValueRef,
ProductionRule,
RectEncodeEntry,
RectMark,
} from 'vega';

import { BarSpecProps, Orientation } from '../../types';
Expand Down Expand Up @@ -254,6 +256,43 @@ export const getStroke = ({ name, children, color, colorScheme }: BarSpecProps):
];
};

export const getDimensionSelectionRing = (props: BarSpecProps): RectMark => {
const { name, colorScheme, paddingRatio, orientation } = props;

const update =
orientation === 'vertical'
? {
y: { value: 0 },
y2: { signal: 'height' },
xc: { signal: `scale('xBand', datum.${name}_selectedGroupId) + bandwidth('xBand')/2` },
width: { signal: `bandwidth('xBand')/(1 - ${paddingRatio} / 2)` },
}
: {
x: { value: 0 },
x2: { signal: 'width' },
yc: { signal: `scale('yBand', datum.${name}_selectedGroupId) + bandwidth('yBand')/2` },
height: { signal: `bandwidth('yBand')/(1 - ${paddingRatio} / 2)` },
};

return {
name: `${name}_selectionRing`,
type: 'rect',
from: {
data: `${name}_selectedData`,
},
interactive: false,
encode: {
enter: {
fill: { value: 'transparent' },
strokeWidth: { value: 2 },
stroke: { value: getColorValue('static-blue', colorScheme) },
cornerRadius: { value: 6 },
},
update,
},
};
};

export const getStrokeDash = ({ children, lineType }: BarSpecProps): ProductionRule<ArrayValueRef> => {
const defaultProductionRule = getStrokeDashProductionRule(lineType);
if (!hasPopover(children)) {
Expand All @@ -263,10 +302,16 @@ export const getStrokeDash = ({ children, lineType }: BarSpecProps): ProductionR
return [{ test: `${SELECTED_ITEM} && ${SELECTED_ITEM} === datum.${MARK_ID}`, value: [] }, defaultProductionRule];
};

export const getStrokeWidth = ({ name, children, lineWidth }: BarSpecProps): ProductionRule<NumericValueRef> => {
export const getStrokeWidth = (props: BarSpecProps): ProductionRule<NumericValueRef> => {
const { lineWidth, name } = props;
const lineWidthValue = getLineWidthPixelsFromLineWidth(lineWidth);
const defaultProductionRule = { value: lineWidthValue };
if (!hasPopover(children)) {
const popovers = getPopovers(props);
const popoverWithDimensionHighlightExists = popovers.some(
({ UNSAFE_highlightBy }) => UNSAFE_highlightBy === 'dimension'
);

if (popovers.length === 0 || popoverWithDimensionHighlightExists) {
return [defaultProductionRule];
}

Expand Down
4 changes: 2 additions & 2 deletions src/stories/components/ChartPopover/ChartPopover.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const ChartPopoverSvgStory: StoryFn<typeof ChartPopover> = (args): ReactElement
<Chart {...chartProps}>
<Bar color="series">
<ChartTooltip>{dialogContent}</ChartTooltip>
<ChartPopover UNSAFE_highlightBy="item" {...args} />
<ChartPopover {...args} />
</Bar>
</Chart>
);
Expand All @@ -75,7 +75,7 @@ const ChartPopoverDodgedBarStory: StoryFn<typeof ChartPopover> = (args): ReactEl
<Chart {...chartProps}>
<Bar color="series" type="dodged">
<ChartTooltip>{dialogContent}</ChartTooltip>
<ChartPopover UNSAFE_highlightBy="item" {...args} />
<ChartPopover {...args} />
</Bar>
</Chart>
);
Expand Down
34 changes: 34 additions & 0 deletions src/stories/components/ChartPopover/ChartPopover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,40 @@ describe('ChartPopover', () => {
expect(bars[4]).toHaveAttribute('stroke-width', '2');
});

test('Dodged bar popover opens on dimension click and closes when clicking outside', async () => {
render(<DodgedBarChart {...DodgedBarChart.args} UNSAFE_highlightBy="dimension" />);

const chart = await findChart();
expect(chart).toBeInTheDocument();
let bars = getAllMarksByGroupName(chart, 'bar0');

// clicking the bar should open the popover
await clickNthElement(bars, 4);
const popover = await screen.findByTestId('rsc-popover');
await waitFor(() => expect(popover).toBeInTheDocument()); // waitFor to give the popover time to make sure it doesn't close

// check the content of the popover
expect(within(popover).getByText('Operating system: Mac')).toBeInTheDocument();
expect(within(popover).getByText('Browser: Firefox')).toBeInTheDocument();
expect(within(popover).getByText('Users: 3')).toBeInTheDocument();

bars = getAllMarksByGroupName(chart, 'bar0');

// validate the highlight visuals are present
expect(bars[0]).toHaveAttribute('opacity', `${1 / HIGHLIGHT_CONTRAST_RATIO}`);
expect(bars[4]).toHaveAttribute('opacity', '1');

const selectionRingMarks = getAllMarksByGroupName(chart, 'bar0_selectionRing');

expect(selectionRingMarks).toHaveLength(3);
expect(selectionRingMarks[0]).toHaveAttribute('stroke', spectrumColors.light['static-blue']);
expect(selectionRingMarks[1]).toHaveAttribute('stroke', spectrumColors.light['static-blue']);
expect(selectionRingMarks[2]).toHaveAttribute('stroke', spectrumColors.light['static-blue']);
expect(selectionRingMarks[0]).toHaveAttribute('stroke-width', '2');
expect(selectionRingMarks[1]).toHaveAttribute('stroke-width', '2');
expect(selectionRingMarks[2]).toHaveAttribute('stroke-width', '2');
});

test('should call onClick callback when selecting a legend entry', async () => {
const onOpenChange = jest.fn();
render(<OnOpenChange {...OnOpenChange.args} onOpenChange={onOpenChange} />);
Expand Down
Loading