Skip to content

Commit e38b0ed

Browse files
authored
Merge pull request #643 from adobe/stackedBarDimensionHoverArea
Dimension Area Tooltips for Stacked Bars
2 parents cb1621c + 0b14860 commit e38b0ed

File tree

21 files changed

+347
-42
lines changed

21 files changed

+347
-42
lines changed

packages/constants/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ export const HOVER_SIZE = 3000;
155155
export const HOVER_SHAPE_COUNT = 3;
156156
export const HOVER_SHAPE = 'diamond';
157157

158+
// tooltip constants
159+
export const DIMENSION_HOVER_AREA = 'dimensionHoverArea';
160+
158161
//SVG Paths
159162
export const ROUNDED_SQUARE_PATH =
160163
'M -0.55 -1 h 1.1 a 0.45 0.45 0 0 1 0.45 0.45 v 1.1 a 0.45 0.45 0 0 1 -0.45 0.45 h -1.1 a 0.45 0.45 0 0 1 -0.45 -0.45 v -1.1 a 0.45 0.45 0 0 1 0.45 -0.45 z';

packages/docs/docs/api/interactivity/ChartTooltip.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,37 @@ const data = [
3333
</Chart>;
3434
```
3535

36+
### Tooltip with dimension area targeting (stacked bars)
37+
38+
```jsx
39+
<Chart data={data}>
40+
<Bar type="stacked">
41+
<ChartTooltip targets={['item']}>
42+
{(datum) => (
43+
<div>
44+
<div>Series: {datum.series}</div>
45+
<div>Value: {datum.value}</div>
46+
</div>
47+
)}
48+
</ChartTooltip>
49+
<ChartTooltip targets={['dimensionArea']}>
50+
{(datum) => (
51+
<div>
52+
<div>Dimension: {datum.dimension}</div>
53+
{datum.rscGroupData?.map((item, index) => (
54+
<div key={index}>
55+
{item.series}: {item.value}
56+
</div>
57+
))}
58+
</div>
59+
)}
60+
</ChartTooltip>
61+
</Bar>
62+
</Chart>
63+
```
64+
65+
In this example, two separate tooltips are defined: one for hovering over the actual bar marks (`item`) that shows individual segment data, and another for hovering anywhere within the dimension area (`dimensionArea`) that shows aggregated data across the dimension. The datum shape differs between these targets, so they require separate tooltip implementations.
66+
3667
## Props
3768
3869
<table>
@@ -63,5 +94,11 @@ const data = [
6394
<td>'item'</td>
6495
<td>Specifies which marks on the parent should be highlighted on hover. For example if set to `dimension`, when a user hovers a mark, it will highlight all marks with the same dimension value.<br/>If an array of strings is provided, each of those key will be used to find other marks that match and should be highlighted. For example, if `highlightBy` is set to `['company', 'quarter']`, when a mark is hovered, all marks with the same company and quarter values will be highlighted.<br/>If `highlightBy` uses `series`, `dimension`, or an array of string, the `item` passed to the tooltip callback will include the `rscGroupData` key. This will have the data for all highlighted marks so that your tooltip can provide info for all the highlighted marks, not just the hovered mark.</td>
6596
</tr>
97+
<tr>
98+
<td>targets</td>
99+
<td>('dimensionArea' | 'item')[]</td>
100+
<td>['item']</td>
101+
<td>Specifies which areas of the chart should trigger the tooltip. `item` will trigger the tooltip when hovering over the actual data mark. `dimensionArea` will trigger the tooltip when hovering anywhere within the dimension area (e.g., for stacked bars, hovering anywhere along the dimension slice).<br/><strong>Note:</strong> Currently only active for stacked bar charts.</td>
102+
</tr>
66103
</tbody>
67104
</table>

packages/react-spectrum-charts/src/components/ChartTooltip/ChartTooltip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { FC } from 'react';
1414
import { ChartTooltipProps } from '../../types';
1515

1616
// eslint-disable-next-line @typescript-eslint/no-unused-vars
17-
const ChartTooltip: FC<ChartTooltipProps> = ({ children, excludeDataKeys, highlightBy = 'item' }) => {
17+
const ChartTooltip: FC<ChartTooltipProps> = ({ children, excludeDataKeys, highlightBy = 'item', targets = ['item'] }) => {
1818
return null;
1919
};
2020

packages/react-spectrum-charts/src/hooks/useTooltipInteractions.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { FC } from 'react';
1414
import { renderToStaticMarkup } from 'react-dom/server';
1515
import { Position, Options as TooltipOptions } from 'vega-tooltip';
1616

17-
import { COMPONENT_NAME, FILTERED_TABLE, GROUP_DATA, GROUP_ID } from '@spectrum-charts/constants';
17+
import { COMPONENT_NAME, DIMENSION_HOVER_AREA, FILTERED_TABLE, GROUP_DATA, GROUP_ID } from '@spectrum-charts/constants';
1818
import { ColorScheme, LegendDescription, TooltipAnchor, TooltipPlacement } from '@spectrum-charts/vega-spec-builder';
1919

2020
import { useChartContext } from '../context/RscChartContext';
@@ -53,6 +53,21 @@ const useTooltipsInteractions = (props: RscChartProps, sanitizedChildren: ChartC
5353
/>
5454
);
5555
}
56+
if (value[COMPONENT_NAME]?.endsWith(DIMENSION_HOVER_AREA)) {
57+
const tooltipName = value[COMPONENT_NAME].replace(`_${DIMENSION_HOVER_AREA}`, '');
58+
const tooltip = tooltips.find((t) => t.name === tooltipName && t.targets?.includes('dimensionArea'));
59+
if (!tooltip) return '';
60+
61+
const tableData = chartView.current?.data(FILTERED_TABLE);
62+
const dimension = value.dimension;
63+
value[GROUP_DATA] = tableData?.filter((d) => d[dimension] === value[dimension]);
64+
65+
return renderToStaticMarkup(
66+
<div className="rsc-tooltip" data-testid="rsc-tooltip">
67+
{tooltip.callback(value)}
68+
</div>
69+
);
70+
}
5671
// get the correct tooltip to render based on the hovered item
5772
const tooltip = tooltips.find((t) => t.name === value[COMPONENT_NAME]);
5873
if (tooltip?.callback && !('index' in value)) {

packages/react-spectrum-charts/src/hooks/useTooltips.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type TooltipDetail = {
2626
name: string;
2727
callback: TooltipHandler;
2828
highlightBy: ChartTooltipProps['highlightBy'];
29+
targets: ChartTooltipProps['targets'];
2930
width?: number;
3031
};
3132

@@ -43,6 +44,7 @@ export default function useTooltips(children: ChartChildElement[]): TooltipDetai
4344
name: tooltip.name,
4445
callback: tooltip.element.props.children,
4546
highlightBy: tooltip.element.props.highlightBy,
47+
targets: tooltip.element.props.targets,
4648
})) as TooltipDetail[],
4749
[tooltipElements]
4850
);

packages/react-spectrum-charts/src/stories/components/Bar/StackedBar.story.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import React, { ReactElement, createElement } from 'react';
1313

1414
import { StoryFn } from '@storybook/react';
1515

16+
import { GROUP_DATA, MARK_ID } from '@spectrum-charts/constants';
1617
import { SpectrumColor } from '@spectrum-charts/vega-spec-builder';
1718

1819
import { Chart } from '../../../Chart';
19-
import { Annotation, Axis, Bar, Legend } from '../../../components';
20+
import { Annotation, Axis, Bar, ChartTooltip, Legend } from '../../../components';
2021
import useChartProps from '../../../hooks/useChartProps';
2122
import { bindWithProps } from '../../../test-utils';
2223
import { BarProps } from '../../../types';
@@ -75,6 +76,42 @@ const NegativeBarStory: StoryFn<typeof Bar> = (args): ReactElement => {
7576
);
7677
};
7778

79+
const DimensionAreaStory: StoryFn<typeof Bar> = (args): ReactElement => {
80+
const chartProps = useChartProps({ data: barSeriesData, width: 800, height: 600 });
81+
return (
82+
<Chart {...chartProps}>
83+
<Axis position={args.orientation === 'horizontal' ? 'left' : 'bottom'} baseline title="Browser" />
84+
<Axis position={args.orientation === 'horizontal' ? 'bottom' : 'left'} grid title="Downloads" />
85+
<Bar {...args}>
86+
<ChartTooltip>
87+
{(datum) => {
88+
return <>
89+
<div>Operating system: {datum.operatingSystem}</div>
90+
<div>Browser: {datum.browser}</div>
91+
<div>Downloads: {datum.value}</div>
92+
</>
93+
}}
94+
</ChartTooltip>
95+
<ChartTooltip targets={['dimensionArea']}>
96+
{(datum) => {
97+
return (
98+
<>
99+
<div style={{ fontWeight: 'bold' }}>{datum.browser} Downloads</div>
100+
{datum[GROUP_DATA]?.map((d) => (
101+
<div key={d[MARK_ID]}>
102+
{d.operatingSystem}: {d.value}
103+
</div>
104+
))}
105+
</>
106+
);
107+
}}
108+
</ChartTooltip>
109+
</Bar>
110+
<Legend title="Operating system" highlight />
111+
</Chart>
112+
);
113+
};
114+
78115
const defaultProps: BarProps = {
79116
dimension: 'browser',
80117
order: 'order',
@@ -117,4 +154,9 @@ StackedBarWithUTCDatetimeFormat.args = {
117154
dimensionDataType: 'time',
118155
};
119156

120-
export { Basic, NegativeStack, WithBarLabels, OnClick, StackedBarWithUTCDatetimeFormat };
157+
const TooltipOnDimensionArea = bindWithProps(DimensionAreaStory);
158+
TooltipOnDimensionArea.args = {
159+
...defaultProps,
160+
};
161+
162+
export { Basic, NegativeStack, WithBarLabels, OnClick, StackedBarWithUTCDatetimeFormat, TooltipOnDimensionArea };
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { DIMENSION_HOVER_AREA, FADE_FACTOR } from '@spectrum-charts/constants';
13+
import { findAllMarksByGroupName, findChart, hoverNthElement, render, screen, unhoverNthElement, within } from '../../../test-utils';
14+
import { TooltipOnDimensionArea } from './StackedBar.story';
15+
16+
describe('TooltipOnDimensionArea', () => {
17+
test('hovering dimension area should apply highlight styling and show tooltip', async () => {
18+
render(<TooltipOnDimensionArea {...TooltipOnDimensionArea.args} />);
19+
const chart = await findChart();
20+
expect(chart).toBeInTheDocument();
21+
const dimensionAreas = await findAllMarksByGroupName(chart, `bar0_${DIMENSION_HOVER_AREA}`);
22+
const bars = await findAllMarksByGroupName(chart, 'bar0');
23+
expect(dimensionAreas).toHaveLength(3);
24+
25+
// hovering dimension area should apply highlight styling and show tooltip
26+
await hoverNthElement(dimensionAreas, 0);
27+
const tooltip = await screen.findByTestId('rsc-tooltip');
28+
expect(tooltip).toBeInTheDocument();
29+
expect(within(tooltip).getByText('Chrome Downloads')).toBeInTheDocument();
30+
expect(bars[0]).toHaveAttribute('opacity', `1`);
31+
expect(bars[4]).toHaveAttribute('opacity', `${FADE_FACTOR}`);
32+
33+
await unhoverNthElement(dimensionAreas, 0);
34+
35+
// hovering bar should do normal stuff
36+
await hoverNthElement(bars, 4);
37+
expect(bars[0]).toHaveAttribute('opacity', `${FADE_FACTOR}`);
38+
expect(bars[4]).toHaveAttribute('opacity', `1`);
39+
});
40+
});

packages/react-spectrum-charts/src/stories/components/MetricRange/MetricRange.story.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const MetricRangeWithPopoverStory: StoryFn<typeof MetricRange> = (args): ReactEl
7878
);
7979
};
8080

81-
const MetricRangescaleAxisToFitStory: StoryFn<typeof MetricRange> = (args): ReactElement => {
81+
const MetricRangeScaleAxisToFitStory: StoryFn<typeof MetricRange> = (args): ReactElement => {
8282
const chartProps = useChartProps({
8383
...defaultChartProps,
8484
data: workspaceTrendsDataWithExtremeMetricRange,
@@ -146,7 +146,7 @@ WithPopover.args = {
146146
displayOnHover: true,
147147
};
148148

149-
const ScaleAxisToFit = bindWithProps(MetricRangescaleAxisToFitStory);
149+
const ScaleAxisToFit = bindWithProps(MetricRangeScaleAxisToFitStory);
150150
ScaleAxisToFit.args = {
151151
lineType: 'shortDash',
152152
lineWidth: 'S',

packages/vega-spec-builder/src/bar/barSpecBuilder.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import {
3030
DEFAULT_METRIC,
3131
DEFAULT_OPACITY_RULE,
3232
DEFAULT_SECONDARY_COLOR,
33+
DIMENSION_HOVER_AREA,
3334
FILTERED_TABLE,
35+
HOVERED_ITEM,
3436
LINE_TYPE_SCALE,
3537
MARK_ID,
3638
OPACITY_SCALE,
@@ -471,6 +473,14 @@ describe('barSpecBuilder', () => {
471473
const lastRscSeriesIdSignal = signals.find(getLastRscSeriesIdSignal);
472474
expect(lastRscSeriesIdSignal).toBeUndefined();
473475
});
476+
477+
test('should add dimension hover area signal if has tooltip with dimension area target', () => {
478+
const signals = addSignals(defaultSignals, {
479+
...defaultBarOptions,
480+
chartTooltips: [{ targets: ['dimensionArea'] }],
481+
});
482+
expect(signals.find((signal) => signal.name === `${defaultBarOptions.name}_${DIMENSION_HOVER_AREA}_${HOVERED_ITEM}`)).toBeDefined();
483+
});
474484
});
475485
});
476486

packages/vega-spec-builder/src/bar/barSpecBuilder.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
DEFAULT_CATEGORICAL_DIMENSION,
1818
DEFAULT_COLOR_SCHEME,
1919
DEFAULT_METRIC,
20+
DIMENSION_HOVER_AREA,
2021
FILTERED_TABLE,
2122
LAST_RSC_SERIES_ID,
2223
LINE_TYPE_SCALE,
@@ -30,7 +31,7 @@ import {
3031
import { toCamelCase } from '@spectrum-charts/utils';
3132

3233
import { addPopoverData, getPopovers } from '../chartPopover/chartPopoverUtils';
33-
import { addTooltipData, addTooltipSignals } from '../chartTooltip/chartTooltipUtils';
34+
import { addTooltipData, addTooltipSignals, hasTooltipWithDimensionAreaTarget } from '../chartTooltip/chartTooltipUtils';
3435
import { addTimeTransform, getTableData, getTransformSort } from '../data/dataUtils';
3536
import { getInteractiveMarkName } from '../marks/markUtils';
3637
import {
@@ -167,6 +168,9 @@ export const addSignals = produce<Signal[], [BarSpecOptions]>((signals, options)
167168
return;
168169
}
169170
addHoveredItemSignal(signals, name, undefined, 1, chartTooltips[0]?.excludeDataKeys);
171+
if (hasTooltipWithDimensionAreaTarget(chartTooltips)) {
172+
addHoveredItemSignal(signals, `${name}_${DIMENSION_HOVER_AREA}`);
173+
}
170174
addTooltipSignals(signals, options);
171175
setTrendlineSignals(signals, options);
172176
});

0 commit comments

Comments
 (0)