Skip to content

Commit

Permalink
Merge pull request #419 from rich-b/rich/dimensionSelectionRing
Browse files Browse the repository at this point in the history
feat: bar dimension selection ring
  • Loading branch information
marshallpete authored Oct 4, 2024
2 parents 7553b3c + f1f3de3 commit 6033b23
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 6 deletions.
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 { getInteractiveMarkName } from '@specBuilder/line/lineUtils';
Expand All @@ -43,7 +43,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 @@ -267,6 +267,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

0 comments on commit 6033b23

Please sign in to comment.