diff --git a/src/specBuilder/marks/markUtils.ts b/src/specBuilder/marks/markUtils.ts index e9490e90d..549a7b35e 100644 --- a/src/specBuilder/marks/markUtils.ts +++ b/src/specBuilder/marks/markUtils.ts @@ -155,7 +155,7 @@ export const getOpacityProductionRule = (opacity: OpacityFacet | DualFacet): { s return { value: opacity.value }; }; -export const getSymbolSizeProductionRule = (symbolSize: SymbolSizeFacet): NumericValueRef | undefined => { +export const getSymbolSizeProductionRule = (symbolSize: SymbolSizeFacet): NumericValueRef => { // key reference for setting symbol size if (typeof symbolSize === 'string') { return { scale: 'symbolSize', field: symbolSize }; diff --git a/src/specBuilder/scatter/scatterMarkUtils.test.ts b/src/specBuilder/scatter/scatterMarkUtils.test.ts index 08a78f108..8bf1e0c8d 100644 --- a/src/specBuilder/scatter/scatterMarkUtils.test.ts +++ b/src/specBuilder/scatter/scatterMarkUtils.test.ts @@ -16,8 +16,9 @@ import { ChartTooltip } from '@components/ChartTooltip'; import { Trendline } from '@components/Trendline'; import { DEFAULT_OPACITY_RULE, MARK_ID } from '@constants'; import { GroupMark } from 'vega'; -import { addScatterMarks, getOpacity, getScatterHoverMarks } from './scatterMarkUtils'; +import { addScatterMarks, getOpacity, getScatterHoverMarks, getSelectRingSize } from './scatterMarkUtils'; import { defaultScatterProps } from './scatterTestUtils'; +import { ChartPopover } from '@components/ChartPopover'; describe('addScatterMarks()', () => { test('should add the scatter group with the symbol marks', () => { @@ -52,6 +53,12 @@ describe('getOpacity()', () => { expect(opacity).toHaveLength(2); expect(opacity[0]).toHaveProperty('test', `scatter0_hoveredId && scatter0_hoveredId !== datum.${MARK_ID}`); }); + test('should include select rule if popover exists', () => { + const opacity = getOpacity({ ...defaultScatterProps, children: [createElement(ChartPopover)] }); + expect(opacity).toHaveLength(3); + expect(opacity[0]).toHaveProperty('test', `scatter0_hoveredId && scatter0_hoveredId !== datum.${MARK_ID}`); + expect(opacity[1]).toHaveProperty('test', `scatter0_selectedId && scatter0_selectedId !== datum.${MARK_ID}`); + }); }); describe('getScatterHoverMarks()', () => { @@ -63,3 +70,16 @@ describe('getScatterHoverMarks()', () => { expect(marks[0].name).toBe('scatter0_voronoi'); }); }); + +describe('getScatterSelectMarks()', () => { + test('should return a staic value if a static value is provided', () => { + const ringSize = getSelectRingSize({ value: 10 }); + // sqrt of 196 is 14 which is 4 pixels larger than 10; + expect(ringSize).toHaveProperty('value', 196); + }); + test('should return a signal if data key is provided', () => { + const sizeKey = 'weight'; + const ringSize = getSelectRingSize(sizeKey); + expect(ringSize).toHaveProperty('signal', `pow(sqrt(scale('symbolSize', datum.${sizeKey})) + 4, 2)`); + }); +}); diff --git a/src/specBuilder/scatter/scatterMarkUtils.ts b/src/specBuilder/scatter/scatterMarkUtils.ts index a632552d5..c3027b994 100644 --- a/src/specBuilder/scatter/scatterMarkUtils.ts +++ b/src/specBuilder/scatter/scatterMarkUtils.ts @@ -9,10 +9,9 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { FILTERED_TABLE, MARK_ID, DEFAULT_OPACITY_RULE } from '@constants'; +import { DEFAULT_OPACITY_RULE, FILTERED_TABLE, HIGHLIGHT_CONTRAST_RATIO, MARK_ID } from '@constants'; import { getColorProductionRule, - getHighlightOpacityValue, getLineWidthProductionRule, getOpacityProductionRule, getStrokeDashProductionRule, @@ -20,10 +19,12 @@ import { getVoronoiPath, getXProductionRule, hasInteractiveChildren, + hasPopover, } from '@specBuilder/marks/markUtils'; import { getTrendlineMarks } from '@specBuilder/trendline'; +import { spectrumColors } from '@themes'; import { produce } from 'immer'; -import { ScatterSpecProps } from 'types'; +import { ScatterSpecProps, SymbolSizeFacet } from 'types'; import { GroupMark, Mark, NumericValueRef, PathMark, SymbolMark } from 'vega'; export const addScatterMarks = produce((marks, props) => { @@ -32,7 +33,7 @@ export const addScatterMarks = produce((marks, props const scatterGroup: GroupMark = { name: `${name}_group`, type: 'group', - marks: [getScatterMark(props), ...getScatterHoverMarks(props)], + marks: [getScatterMark(props), ...getScatterHoverMarks(props), ...getScatterSelectMarks(props)], }; marks.push(scatterGroup); @@ -88,15 +89,25 @@ export const getOpacity = ({ children, name }: ScatterSpecProps): ({ test?: stri if (!hasInteractiveChildren(children)) { return [DEFAULT_OPACITY_RULE]; } - // if a point is hovered, all other points should be reduced opacity + // if a point is hovered or selected, all other points should be reduced opacity const hoverSignal = `${name}_hoveredId`; - return [ + const selectSignal = `${name}_selectedId`; + const fadedValue = 1 / HIGHLIGHT_CONTRAST_RATIO; + + const rules = [ { test: `${hoverSignal} && ${hoverSignal} !== datum.${MARK_ID}`, - ...getHighlightOpacityValue(), + value: fadedValue, }, - DEFAULT_OPACITY_RULE, ]; + if (hasPopover(children)) { + rules.push({ + test: `${selectSignal} && ${selectSignal} !== datum.${MARK_ID}`, + value: fadedValue, + }); + } + + return [...rules, DEFAULT_OPACITY_RULE]; }; /** @@ -110,3 +121,56 @@ export const getScatterHoverMarks = ({ children, name }: ScatterSpecProps): Path } return [getVoronoiPath(children, name, name)]; }; + +const getScatterSelectMarks = ({ + children, + dimension, + dimensionScaleType, + metric, + name, + size, +}: ScatterSpecProps): SymbolMark[] => { + if (!hasPopover(children)) { + return []; + } + return [ + { + name: `${name}_selectRing`, + type: 'symbol', + from: { + data: `${name}_selectedData`, + }, + encode: { + enter: { + fill: { value: 'transparent' }, + shape: { value: 'circle' }, + size: getSelectRingSize(size), + strokeWidth: { value: 2 }, + stroke: { value: spectrumColors.light['static-blue'] }, + }, + update: { + x: getXProductionRule(dimensionScaleType, dimension), + y: { scale: 'yLinear', field: metric }, + }, + }, + }, + ]; +}; + +/** + * Gets the size of the select ring based on the size of the scatter points + * @param size SymbolSizeFacet + * @returns NumericValueRef + */ +export const getSelectRingSize = (size: SymbolSizeFacet): NumericValueRef => { + const baseSize = getSymbolSizeProductionRule(size); + if ('value' in baseSize && typeof baseSize.value === 'number') { + // the select ring is 4px widr and taller + // to calculate: (sqrt(baseSize) + 4)^2 + return { value: Math.pow(Math.sqrt(baseSize.value) + 4, 2) }; + } + if ('scale' in baseSize && 'field' in baseSize) { + return { signal: `pow(sqrt(scale('${baseSize.scale}', datum.${baseSize.field})) + 4, 2)` }; + } + return baseSize; +}; diff --git a/src/specBuilder/scatter/scatterSpecBuilder.test.ts b/src/specBuilder/scatter/scatterSpecBuilder.test.ts index 26a89f20e..635346e8c 100644 --- a/src/specBuilder/scatter/scatterSpecBuilder.test.ts +++ b/src/specBuilder/scatter/scatterSpecBuilder.test.ts @@ -18,6 +18,7 @@ import { initializeSpec } from '@specBuilder/specUtils'; import { addData, addSignals, setScales } from './scatterSpecBuilder'; import { defaultScatterProps } from './scatterTestUtils'; import { ChartTooltip } from '@components/ChartTooltip'; +import { ChartPopover } from '@components/ChartPopover'; describe('addData()', () => { test('should add time transform is dimensionScaleType === "time"', () => { @@ -26,6 +27,14 @@ describe('addData()', () => { expect(data[0].transform).toHaveLength(2); expect(data[0].transform?.[1].type).toBe('timeunit'); }); + test('should add selectedData if popover exists', () => { + const data = addData(initializeSpec().data ?? [], { + ...defaultScatterProps, + children: [createElement(ChartPopover)], + }); + expect(data).toHaveLength(3); + expect(data[2].name).toBe('scatter0_selectedData'); + }); test('should add trendline data if trendline exists as a child', () => { const data = addData(initializeSpec().data ?? [], { ...defaultScatterProps, @@ -44,7 +53,15 @@ describe('addSignals()', () => { children: [createElement(ChartTooltip)], }); expect(signals).toHaveLength(1); - expect(signals[0]).toHaveProperty('name', 'scatter0_hoveredId'); + expect(signals[0].name).toBe('scatter0_hoveredId'); + }); + test('should add selectedId signal if popover exists', () => { + const signals = addSignals([], { + ...defaultScatterProps, + children: [createElement(ChartPopover)], + }); + expect(signals).toHaveLength(2); + expect(signals[1].name).toBe('scatter0_selectedId'); }); test('should add trendline signals if trendline exists as a child', () => { const signals = addSignals([], { @@ -52,8 +69,8 @@ describe('addSignals()', () => { children: [createElement(Trendline, { displayOnHover: true })], }); expect(signals).toHaveLength(2); - expect(signals[0]).toHaveProperty('name', 'scatter0_hoveredSeries'); - expect(signals[1]).toHaveProperty('name', 'scatter0_hoveredId'); + expect(signals[0].name).toBe('scatter0_hoveredSeries'); + expect(signals[1].name).toBe('scatter0_hoveredId'); }); }); diff --git a/src/specBuilder/scatter/scatterSpecBuilder.ts b/src/specBuilder/scatter/scatterSpecBuilder.ts index 71394f0ba..881853154 100644 --- a/src/specBuilder/scatter/scatterSpecBuilder.ts +++ b/src/specBuilder/scatter/scatterSpecBuilder.ts @@ -14,16 +14,18 @@ import { DEFAULT_DIMENSION_SCALE_TYPE, DEFAULT_LINEAR_DIMENSION, DEFAULT_METRIC, + FILTERED_TABLE, + MARK_ID, } from '@constants'; import { addTimeTransform, getTableData } from '@specBuilder/data/dataUtils'; import { getInteractiveMarkName } from '@specBuilder/line/lineUtils'; -import { hasInteractiveChildren } from '@specBuilder/marks/markUtils'; +import { hasInteractiveChildren, hasPopover } from '@specBuilder/marks/markUtils'; import { addContinuousDimensionScale, addFieldToFacetScaleDomain, addMetricScale, } from '@specBuilder/scale/scaleSpecBuilder'; -import { getUncontrolledHoverSignal, hasSignalByName } from '@specBuilder/signal/signalSpecBuilder'; +import { getGenericSignal, getUncontrolledHoverSignal, hasSignalByName } from '@specBuilder/signal/signalSpecBuilder'; import { addTrendlineData, getTrendlineScales, getTrendlineSignals } from '@specBuilder/trendline'; import { sanitizeMarkChildren, toCamelCase } from '@utils'; import { produce } from 'immer'; @@ -85,11 +87,23 @@ export const addScatter = produce((data, props) => { - const { dimension, dimensionScaleType } = props; + const { children, dimension, dimensionScaleType, name } = props; if (dimensionScaleType === 'time') { const tableData = getTableData(data); tableData.transform = addTimeTransform(tableData.transform ?? [], dimension); } + if (hasPopover(children)) { + data.push({ + name: `${name}_selectedData`, + source: FILTERED_TABLE, + transform: [ + { + type: 'filter', + expr: `${name}_selectedId === datum.${MARK_ID}`, + }, + ], + }); + } addTrendlineData(data, props); }); @@ -106,8 +120,15 @@ export const addSignals = produce((signals, props) if (!hasInteractiveChildren(children)) return; // interactive signals if (!hasSignalByName(signals, `${name}_hoveredId`)) { + // hover signal signals.push(getUncontrolledHoverSignal(`${name}`, true, `${name}_voronoi`)); } + if (hasPopover(children)) { + if (!hasSignalByName(signals, `${name}_selectedId`)) { + // select signal + signals.push(getGenericSignal(`${name}_selectedId`)); + } + } }); /** diff --git a/src/stories/components/Scatter/Scatter.story.tsx b/src/stories/components/Scatter/Scatter.story.tsx index 4f74b69b9..653f3059c 100644 --- a/src/stories/components/Scatter/Scatter.story.tsx +++ b/src/stories/components/Scatter/Scatter.story.tsx @@ -9,11 +9,23 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { ReactElement } from 'react'; +import { ReactElement, createElement } from 'react'; import { Content, Flex } from '@adobe/react-spectrum'; import useChartProps from '@hooks/useChartProps'; -import { Axis, Chart, ChartProps, ChartTooltip, Datum, Legend, LegendProps, Scatter, ScatterProps, Title } from '@rsc'; +import { + Axis, + Chart, + ChartPopover, + ChartProps, + ChartTooltip, + Datum, + Legend, + LegendProps, + Scatter, + ScatterProps, + Title, +} from '@rsc'; import { characterData } from '@stories/data/marioKartData'; import { StoryFn } from '@storybook/react'; import { bindWithProps } from '@test-utils'; @@ -98,41 +110,22 @@ const ScatterStory: StoryFn = (args): ReactElement => { - - - </Chart> - ); -}; - -const ScatterTooltipStory: StoryFn<typeof Scatter> = (args): ReactElement => { - const chartProps = useChartProps(defaultChartProps); - const legendProps = getLegendProps(args); - const dimension = args.dimension as MarioDataKey; - const metric = args.metric as MarioDataKey; - - return ( - <Chart {...chartProps}> - <Axis position="bottom" grid ticks baseline title={marioKeyTitle[dimension]} /> - <Axis position="left" grid ticks baseline title={marioKeyTitle[metric]} /> - <Scatter {...args}> - <ChartTooltip>{(item) => dialog(item, dimension, metric)}</ChartTooltip> - </Scatter> <Legend {...legendProps} highlight /> <Title text="Mario Kart 8 Character Data" /> </Chart> ); }; -const dialog = (item: Datum, dimension: MarioDataKey, metric: MarioDataKey) => { +const dialog = (item: Datum) => { return ( <Content> <Flex direction="column"> <div style={{ fontWeight: 'bold' }}>{(item.character as string[]).join(', ')}</div> <div> - {marioKeyTitle[dimension]}: {item[dimension]} + {marioKeyTitle.speedNormal}: {item.speedNormal} </div> <div> - {marioKeyTitle[metric]}: {item[metric]} + {marioKeyTitle.handlingNormal}: {item.handlingNormal} </div> </Flex> </Content> @@ -168,6 +161,14 @@ Opacity.args = { metric: 'handlingNormal', }; +const Popover = bindWithProps(ScatterStory); +Popover.args = { + color: 'weightClass', + dimension: 'speedNormal', + metric: 'handlingNormal', + children: [createElement(ChartTooltip, {}, dialog), createElement(ChartPopover, { width: 200 }, dialog)], +}; + const Size = bindWithProps(ScatterStory); Size.args = { size: 'weight', @@ -175,11 +176,12 @@ Size.args = { metric: 'handlingNormal', }; -const Tooltip = bindWithProps(ScatterTooltipStory); +const Tooltip = bindWithProps(ScatterStory); Tooltip.args = { color: 'weightClass', dimension: 'speedNormal', metric: 'handlingNormal', + children: [createElement(ChartTooltip, {}, dialog)], }; -export { Basic, Color, LineType, Opacity, Size, Tooltip }; +export { Basic, Color, LineType, Opacity, Popover, Size, Tooltip }; diff --git a/src/stories/components/Scatter/Scatter.test.tsx b/src/stories/components/Scatter/Scatter.test.tsx index 2d2cc8c71..93b0e9f93 100644 --- a/src/stories/components/Scatter/Scatter.test.tsx +++ b/src/stories/components/Scatter/Scatter.test.tsx @@ -12,8 +12,10 @@ import { Scatter, spectrumColors } from '@rsc'; import { allElementsHaveAttributeValue, + clickNthElement, findAllMarksByGroupName, findChart, + findMarksByGroupName, getAllLegendEntries, hoverNthElement, render, @@ -21,8 +23,9 @@ import { within, } from '@test-utils'; -import { Basic, Color, LineType, Opacity, Size, Tooltip } from './Scatter.story'; +import { Basic, Color, LineType, Opacity, Popover, Size, Tooltip } from './Scatter.story'; import { HIGHLIGHT_CONTRAST_RATIO } from '@constants'; +import userEvent from '@testing-library/user-event'; const colors = spectrumColors.light; @@ -82,6 +85,53 @@ describe('Scatter', () => { expect(points[11]).toHaveAttribute('fill-opacity', '0.5'); }); + describe('Popover', () => { + test('should render a popover on hover', async () => { + render(<Popover {...Popover.args} />); + + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + + const paths = await findAllMarksByGroupName(chart, 'scatter0_voronoi'); + + await clickNthElement(paths, 0); + let popover = await screen.findByTestId('rsc-popover'); + expect(popover).toBeInTheDocument(); + expect(within(popover).getByText('Baby Peach, Baby Daisy')).toBeInTheDocument(); + + await userEvent.click(document.body); + + await clickNthElement(paths, 12); + popover = await screen.findByTestId('rsc-popover'); + expect(within(popover).getByText('Metal Mario, Gold Mario, Pink Gold Peach')).toBeInTheDocument(); + }); + + test('should highlight hovered points', async () => { + render(<Popover {...Popover.args} />); + + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + + const paths = await findAllMarksByGroupName(chart, 'scatter0_voronoi'); + const points = await findAllMarksByGroupName(chart, 'scatter0'); + expect(points).toHaveLength(16); + + await clickNthElement(paths, 0); + expect(points[0]).toHaveAttribute('opacity', '1'); + + // make sure all points after the first have reduced opacity + expect( + allElementsHaveAttributeValue(points.slice(1), 'opacity', 1 / HIGHLIGHT_CONTRAST_RATIO), + ).toBeTruthy(); + + // find the select ring + const selectRing = await findMarksByGroupName(chart, 'scatter0_selectRing'); + expect(selectRing).toBeInTheDocument(); + expect(selectRing).toHaveAttribute('stroke', spectrumColors.light['static-blue']); + expect(selectRing).toHaveAttribute('stroke-width', '2'); + }); + }); + test('Size renders properly', async () => { render(<Size {...Size.args} />); @@ -101,7 +151,7 @@ describe('Scatter', () => { describe('Tooltip', () => { test('should render a tooltip on hover', async () => { - render(<Tooltip {...Basic.args} />); + render(<Tooltip {...Tooltip.args} />); const chart = await findChart(); expect(chart).toBeInTheDocument(); @@ -117,8 +167,8 @@ describe('Scatter', () => { tooltip = await screen.findByTestId('rsc-tooltip'); expect(within(tooltip).getByText('Metal Mario, Gold Mario, Pink Gold Peach')).toBeInTheDocument(); }); - test('should highligh hovered points', async () => { - render(<Tooltip {...Basic.args} />); + test('should highlight hovered points', async () => { + render(<Tooltip {...Tooltip.args} />); const chart = await findChart(); expect(chart).toBeInTheDocument();