diff --git a/package.json b/package.json index 129d1886c..afbcece2e 100644 --- a/package.json +++ b/package.json @@ -152,8 +152,8 @@ "d3-format": "^3.1.0", "immer": ">= 9.0.0", "uuid": ">= 9.0.0", - "vega-embed": ">= 6.24.0", - "vega-tooltip": ">= 0.26.0" + "vega-embed": ">= 6.27.0", + "vega-tooltip": ">= 0.35.2" }, "peerDependencies": { "@adobe/react-spectrum": ">=3.23.0", diff --git a/src/Chart.tsx b/src/Chart.tsx index 8a1082475..a4225a665 100644 --- a/src/Chart.tsx +++ b/src/Chart.tsx @@ -45,9 +45,7 @@ export const Chart = forwardRef( data, colors = 'categorical12', colorScheme = DEFAULT_COLOR_SCHEME, - config, dataTestId, - description, debug = false, emptyStateText = 'No data found', height = 300, @@ -61,13 +59,12 @@ export const Chart = forwardRef( maxHeight = Infinity, minWidth = 100, maxWidth = Infinity, - opacities, padding = 0, renderer = 'svg', - symbolShapes, - symbolSizes, theme = defaultTheme, title, + tooltipAnchor = 'cursor', + tooltipPlacement = 'top', width = 'auto', UNSAFE_vegaSpec, ...props @@ -118,29 +115,27 @@ export const Chart = forwardRef( } const rscChartProps: RscChartProps = { - chartView: chartView, - chartId: chartId, - data: data, - backgroundColor: backgroundColor, - colors: colors, - colorScheme: colorScheme, - config: config, - description: description, - debug: debug, - hiddenSeries: hiddenSeries, - highlightedSeries: highlightedSeries, - lineTypes: lineTypes, - lineWidths: lineWidths, - locale: locale, - opacities: opacities, - padding: padding, - renderer: renderer, - symbolShapes: symbolShapes, - symbolSizes: symbolSizes, - title: title, - chartWidth: chartWidth, - chartHeight: chartHeight, - UNSAFE_vegaSpec: UNSAFE_vegaSpec, + chartView, + chartId, + data, + backgroundColor, + colors, + colorScheme, + debug, + hiddenSeries, + highlightedSeries, + lineTypes, + lineWidths, + locale, + padding, + renderer, + title, + tooltipAnchor, + tooltipPlacement, + chartWidth, + chartHeight, + UNSAFE_vegaSpec, + ...props, }; const bigNumberElements = getBigNumberElementsFromChildren(props.children); diff --git a/src/RscChart.tsx b/src/RscChart.tsx index 751122f73..17fdad4cd 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -13,10 +13,6 @@ import { FC, MutableRefObject, forwardRef, useEffect, useMemo, useRef, useState import { COMPONENT_NAME, - DEFAULT_BACKGROUND_COLOR, - DEFAULT_COLOR_SCHEME, - DEFAULT_LINE_TYPES, - DEFAULT_LOCALE, FILTERED_TABLE, GROUP_DATA, LEGEND_TOOLTIP_DELAY, @@ -44,13 +40,22 @@ import { } from '@utils'; import { renderToStaticMarkup } from 'react-dom/server'; import { Item } from 'vega'; -import { Handler, Options as TooltipOptions } from 'vega-tooltip'; +import { Handler, Position, Options as TooltipOptions } from 'vega-tooltip'; import { ActionButton, Dialog, DialogTrigger, View as SpectrumView } from '@adobe/react-spectrum'; import './Chart.css'; import { VegaChart } from './VegaChart'; -import { ChartHandle, Datum, LegendDescription, LineType, MarkBounds, RscChartProps } from './types'; +import { + ChartHandle, + ColorScheme, + Datum, + LegendDescription, + MarkBounds, + RscChartProps, + TooltipAnchor, + TooltipPlacement, +} from './types'; interface ChartDialogProps { datum: Datum | null; @@ -69,25 +74,27 @@ export const RscChart = forwardRef( ( { chartView, - backgroundColor = DEFAULT_BACKGROUND_COLOR, + backgroundColor, data, - colors = 'categorical12', - colorScheme = DEFAULT_COLOR_SCHEME, + colors, + colorScheme, config, description, - debug = false, - hiddenSeries = [], + debug, + hiddenSeries, highlightedSeries, - lineTypes = DEFAULT_LINE_TYPES as LineType[], - lineWidths = ['M'], - locale = DEFAULT_LOCALE, + lineTypes, + lineWidths, + locale, opacities, - padding = 0, - renderer = 'svg', + padding, + renderer, symbolShapes, symbolSizes, title, - chartHeight = 300, + tooltipAnchor, + tooltipPlacement, + chartHeight, chartWidth, UNSAFE_vegaSpec, chartId, @@ -162,10 +169,10 @@ export const RscChart = forwardRef( padding ); - const tooltipConfig: TooltipOptions = { theme: colorScheme }; + const tooltipOptions = getTooltipOptions(colorScheme, tooltipAnchor, tooltipPlacement); if (tooltips.length || legendDescriptions) { - tooltipConfig.formatTooltip = (value) => { + tooltipOptions.formatTooltip = (value) => { debugLog(debug, { title: 'Tooltip datum', contents: value }); if (value[COMPONENT_NAME]?.startsWith('legend') && legendDescriptions && 'index' in value) { debugLog(debug, { @@ -239,13 +246,13 @@ export const RscChart = forwardRef( locale={locale} padding={padding} signals={signals} - tooltip={tooltipConfig} // legend show/hide relies on this + tooltip={tooltipOptions} // legend show/hide relies on this onNewView={(view) => { chartView.current = view; // Add a delay before displaying legend tooltips on hover. let tooltipTimeout: NodeJS.Timeout | undefined; - view.tooltip((_, event, item, value) => { - const tooltipHandler = new Handler(tooltipConfig); + view.tooltip((viewRef, event, item, value) => { + const tooltipHandler = new Handler(tooltipOptions); // Cancel delayed tooltips if the mouse moves before the delay is resolved. if (tooltipTimeout) { clearTimeout(tooltipTimeout); @@ -253,11 +260,11 @@ export const RscChart = forwardRef( } if (event && event.type === 'pointermove' && itemIsLegendItem(item) && 'tooltip' in item) { tooltipTimeout = setTimeout(() => { - tooltipHandler.call(this, event, item, value); + tooltipHandler.call(viewRef, event, item, value); tooltipTimeout = undefined; }, LEGEND_TOOLTIP_DELAY); } else { - tooltipHandler.call(this, event, item, value); + tooltipHandler.call(viewRef, event, item, value); } }); if (popovers.length || onMarkClicks.length || legendIsToggleable || onLegendClick) { @@ -336,6 +343,29 @@ const ChartDialog = ({ datum, popover, setIsPopoverOpen, targetElement }: ChartD ); }; +const getTooltipOptions = ( + colorScheme: ColorScheme, + tooltipAnchor: TooltipAnchor, + tooltipPlacement: TooltipPlacement +): TooltipOptions => { + const position: Record<'top' | 'bottom' | 'right' | 'left', Position[]> = { + top: ['top', 'bottom', 'right', 'left', 'top-right', 'top-left', 'bottom-right', 'bottom-left'], + bottom: ['bottom', 'top', 'right', 'left', 'bottom-right', 'bottom-left', 'top-right', 'top-left'], + right: ['right', 'left', 'top', 'bottom', 'top-right', 'bottom-right', 'top-left', 'bottom-left'], + left: ['left', 'right', 'top', 'bottom', 'top-left', 'bottom-left', 'top-right', 'bottom-right'], + }; + + const offset = tooltipAnchor === 'cursor' ? 10 : 6; + + return { + theme: colorScheme, + anchor: tooltipAnchor, + position: tooltipAnchor === 'mark' ? position[tooltipPlacement] : undefined, + offsetX: offset, + offsetY: offset, + }; +}; + const LegendTooltip: FC = ({ value, descriptions, domain }) => { const series = domain[value.index]; const description = descriptions.find((d) => d.seriesName === series); diff --git a/src/stories/Chart.story.tsx b/src/stories/Chart.story.tsx index a791f129b..26afbdfd2 100644 --- a/src/stories/Chart.story.tsx +++ b/src/stories/Chart.story.tsx @@ -9,10 +9,10 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import React, { ReactElement } from 'react'; +import { ReactElement } from 'react'; import useChartProps from '@hooks/useChartProps'; -import { Axis, Chart, Line } from '@rsc'; +import { Axis, Bar, Chart, ChartTooltip, Line } from '@rsc'; import { StoryFn } from '@storybook/react'; import { bindWithProps } from '@test-utils'; @@ -47,6 +47,26 @@ const ChartTimeStory: StoryFn = (args): ReactElement => { ); }; +const ChartBarTooltipStory: StoryFn = (args): ReactElement => { + const props = useChartProps(args); + return ( + + + + + + {(datum) => ( +
+
x: {datum.x}
+
y: {datum.y}
+
+ )} +
+
+
+ ); +}; + const Basic = bindWithProps(ChartLineStory); // Story specific props are passed here @@ -91,4 +111,11 @@ Height.args = { data, }; -export { Basic, BackgroundColor, Config, Locale, Width, Height }; +const TooltipAnchor = bindWithProps(ChartBarTooltipStory); +TooltipAnchor.args = { + tooltipAnchor: 'mark', + tooltipPlacement: 'top', + data, +}; + +export { BackgroundColor, Basic, Config, Height, Locale, TooltipAnchor, Width }; diff --git a/src/stories/Chart.test.tsx b/src/stories/Chart.test.tsx index 45089b2f9..25f8962bf 100644 --- a/src/stories/Chart.test.tsx +++ b/src/stories/Chart.test.tsx @@ -13,10 +13,10 @@ import React, { createRef } from 'react'; import '@matchMediaMock'; import { Axis, Bar, Chart, ChartHandle, ChartTooltip, Line } from '@rsc'; -import { findChart, getAllMarksByGroupName, render, screen } from '@test-utils'; +import { findChart, getAllMarksByGroupName, hoverNthElement, render, screen } from '@test-utils'; import { getElement } from '@utils'; -import { BackgroundColor, Basic, Config, Height, Locale, Width } from './Chart.story'; +import { BackgroundColor, Basic, Config, Height, Locale, TooltipAnchor, Width } from './Chart.story'; import { CssColors, SpectrumColorNames, @@ -298,4 +298,59 @@ describe('Chart', () => { expect(getElement(PopoverTest, Line)).toStrictEqual(undefined); }); }); + + describe('TooltipAnchor()', () => { + // get the integer value from a px string + const getPxValue = (pxString: string) => parseInt(pxString.replace('px', ''), 10); + + test('should render the tooltip relative to the cursor if `tooltipAnchor` is set to `cursor`', async () => { + render(); + + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + const bars = getAllMarksByGroupName(chart, 'bar0'); + + await hoverNthElement(bars, 0); + const tooltip = document.getElementById('vg-tooltip-element'); + expect(tooltip).toBeInTheDocument(); + if (!tooltip) return; + + // will be at 10, 10 since the cursor is at 0, 0 and the offset is 10 + expect(getPxValue(tooltip.style.getPropertyValue('top'))).toBe(10); + expect(getPxValue(tooltip.style.getPropertyValue('left'))).toBe(10); + }); + + describe('tooltipAnchor = mark', () => {}); + + test('should render the tooltip relative to the mark if `tooltipAnchor` is set to `mark`', async () => { + render(); + + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + const bars = getAllMarksByGroupName(chart, 'bar0'); + + await hoverNthElement(bars, 0); + const tooltip = document.getElementById('vg-tooltip-element'); + expect(tooltip).toBeInTheDocument(); + if (!tooltip) return; + + expect(getPxValue(tooltip.style.getPropertyValue('top'))).toBe(239); + expect(getPxValue(tooltip.style.getPropertyValue('left'))).toBe(35); + }); + + test('should render the tooltip to the right of the mark if placement is right', async () => { + render(); + const chart = await findChart(); + expect(chart).toBeInTheDocument(); + const bars = getAllMarksByGroupName(chart, 'bar0'); + + await hoverNthElement(bars, 0); + const tooltip = document.getElementById('vg-tooltip-element'); + expect(tooltip).toBeInTheDocument(); + if (!tooltip) return; + + expect(getPxValue(tooltip.style.getPropertyValue('top'))).toBe(284); + expect(getPxValue(tooltip.style.getPropertyValue('left'))).toBe(35); + }); + }); }); diff --git a/src/types/Chart.ts b/src/types/Chart.ts index 09ef46149..08b81704a 100644 --- a/src/types/Chart.ts +++ b/src/types/Chart.ts @@ -21,6 +21,8 @@ import { Theme } from '@react-types/provider'; import { Colors, SpectrumColor } from './SpectrumVizColors'; import { LocaleCode, NumberLocaleCode, TimeLocaleCode } from './locales'; +export type PartiallyRequired = Omit & Required>; + export type AnnotationElement = ReactElement>; export type AreaElement = ReactElement>; export type AxisElement = ReactElement>; @@ -100,6 +102,8 @@ export type Opacities = number[] | number[][]; export type SymbolShapes = ChartSymbolShape[] | ChartSymbolShape[][]; export type ChartSymbolShape = 'rounded-square' | SymbolShape; export type NumberFormat = 'currency' | 'shortCurrency' | 'shortNumber' | 'standardNumber'; +export type TooltipAnchor = 'cursor' | 'mark'; +export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'; export interface SharedChartProps extends SpecProps { /** Vega config that can be used to tweak the style of the chart. @see https://vega.github.io/vega/docs/config/ */ @@ -114,9 +118,27 @@ export interface SharedChartProps extends SpecProps { padding?: Padding; /** Method to use for rendering the chart. 'canvas' is ideal for large data sets. */ renderer?: 'svg' | 'canvas'; -} - -export interface RscChartProps extends SharedChartProps { + /** Sets what the tooltip should be anchored to. Defaults to `cursor`. */ + tooltipAnchor?: TooltipAnchor; + /** The placement of the tooltip with respect to the mark. Only applicable if `tooltipAnchor = 'mark'`. */ + tooltipPlacement?: TooltipPlacement; +} + +type ChartPropsWithDefaults = + | 'backgroundColor' + | 'colors' + | 'colorScheme' + | 'debug' + | 'locale' + | 'padding' + | 'renderer' + | 'lineTypes' + | 'lineWidths' + | 'hiddenSeries' + | 'tooltipAnchor' + | 'tooltipPlacement'; + +export interface RscChartProps extends PartiallyRequired { chartId: MutableRefObject; chartView: MutableRefObject; chartWidth: number; diff --git a/yarn.lock b/yarn.lock index 8f492ec7c..ada2d5a0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3813,6 +3813,11 @@ dependencies: "@react-types/shared" "^3.22.1" +"@rollup/rollup-linux-x64-gnu@^4.24.4": + version "4.26.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.26.0.tgz#9909570be5cb738c23858c94308d37dde363eb7e" + integrity sha512-ZuwpfjCwjPkAOxpjAEjabg6LRSfL7cAJb6gSQGZYjGhadlzKKywDkCUnJ+KEfrNY1jH5EEoSIKLCb572jSiglA== + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -11008,7 +11013,7 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2: +semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== @@ -11787,11 +11792,16 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@~2.6.2: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@~2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@^2.6.2, tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -12138,19 +12148,19 @@ vega-dataflow@^5.7.3, vega-dataflow@^5.7.5, vega-dataflow@~5.7.5: vega-loader "^4.5.1" vega-util "^1.17.1" -"vega-embed@>= 6.24.0": - version "6.24.0" - resolved "https://registry.yarnpkg.com/vega-embed/-/vega-embed-6.24.0.tgz#2ac8dbfce20070e6c46785af725ec28cb80f9b0c" - integrity sha512-ANCksO3lXhdLzQn7Mfistm1dsRwQhxTYICVpru7TMc+Ywe7C4vOLWuaVv9Qvem2IgQyuVCal0EoTMKvIVYykFg== +"vega-embed@>= 6.27.0": + version "6.28.0" + resolved "https://registry.yarnpkg.com/vega-embed/-/vega-embed-6.28.0.tgz#18ea7ea24e12df4d6075496ce4dd601fca23ce0d" + integrity sha512-QCjrNCDZPrSOZPG3UmfFZsd95mUQEZSYAWdoi2TOEnzBv/NzB+BX+Fc6jdpcAHsORn3TqxL0um/jktyjnV88zg== dependencies: fast-json-patch "^3.1.1" json-stringify-pretty-compact "^3.0.0" - semver "^7.5.4" - tslib "^2.6.2" + semver "^7.6.3" + tslib "^2.8.1" vega-interpreter "^1.0.5" vega-schema-url-parser "^2.2.0" - vega-themes "^2.14.0" - vega-tooltip "^0.34.0" + vega-themes "^2.15.0" + vega-tooltip "^0.35.1" vega-encode@~4.10.0: version "4.10.0" @@ -12380,10 +12390,10 @@ vega-statistics@^1.8.1, vega-statistics@^1.9.0, vega-statistics@~1.9.0: dependencies: d3-array "^3.2.2" -vega-themes@^2.14.0: - version "2.14.0" - resolved "https://registry.yarnpkg.com/vega-themes/-/vega-themes-2.14.0.tgz#0df269396e057123ecf3942e3b704bf125d1eed7" - integrity sha512-9dLmsUER7gJrDp8SEYKxBFmXmpyzLlToKIjxq3HCvYjz8cnNrRGyAhvIlKWOB3ZnGvfYV+vnv3ZRElSNL31nkA== +vega-themes@^2.15.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/vega-themes/-/vega-themes-2.15.0.tgz#cf7592efb45406957e9beb67d7033ee5f7b7a511" + integrity sha512-DicRAKG9z+23A+rH/3w3QjJvKnlGhSbbUXGjBvYGseZ1lvj9KQ0BXZ2NS/+MKns59LNpFNHGi9us/wMlci4TOA== vega-time@^2.1.1, vega-time@~2.1.1: version "2.1.1" @@ -12394,12 +12404,23 @@ vega-time@^2.1.1, vega-time@~2.1.1: d3-time "^3.1.0" vega-util "^1.17.1" -"vega-tooltip@>= 0.26.0", vega-tooltip@^0.34.0: - version "0.34.0" - resolved "https://registry.yarnpkg.com/vega-tooltip/-/vega-tooltip-0.34.0.tgz#e0aa4d9c9bcf155e257650ba7e670fad7b1ff5ab" - integrity sha512-TtxwkcLZ5aWQTvKGlfWDou8tISGuxmqAW1AgGZjrDpf75qsXvgtbPdRAAls2LZMqDxpr5T1kMEZs9XbSpiI8yw== +"vega-tooltip@>= 0.35.2": + version "0.35.2" + resolved "https://registry.yarnpkg.com/vega-tooltip/-/vega-tooltip-0.35.2.tgz#a019133d481ce1e0876c0dc948a0a13703d48bcc" + integrity sha512-kuYcsAAKYn39ye5wKf2fq1BAxVcjoz0alvKp/G+7BWfIb94J0PHmwrJ5+okGefeStZnbXxINZEOKo7INHaj9GA== dependencies: vega-util "^1.17.2" + optionalDependencies: + "@rollup/rollup-linux-x64-gnu" "^4.24.4" + +vega-tooltip@^0.35.1: + version "0.35.1" + resolved "https://registry.yarnpkg.com/vega-tooltip/-/vega-tooltip-0.35.1.tgz#a789bda75b9ca79b41401bf3c045a6e0cc3a1ca2" + integrity sha512-KpPa3H0tE3bTr0DFho1qJUygRLmVy8+iAnEnJvPaQIdI6QBcvFqrkHoA23QhTFtRLNdHaGzmuxoJNWJ1TcIyzg== + dependencies: + vega-util "^1.17.2" + optionalDependencies: + "@rollup/rollup-linux-x64-gnu" "^4.24.4" vega-transforms@~4.11.1: version "4.11.1"