From 5a95192deb6ac07aaa79c3e6df9eaebe7f6c3b45 Mon Sep 17 00:00:00 2001 From: Marshall Peterson <40001449+marshallpete@users.noreply.github.com> Date: Tue, 5 Nov 2024 11:49:24 -0700 Subject: [PATCH] feat: anchor tooltip to mark (#959) * feat: enable positioning the tooltip relative to the hovered mark * refactor: clean up * test: add coverage for calculatePositionRelativeToCursor() * feat: added x and y offset for tooltips relative to hovered marks * chore: made diagonal positioning consistent for relative mark tooltips * test: add test coverage * chore: clean up * chore: clean up * trigger build * build: remove unnecessary script * chore: simplify statement * refactor: use utility methods to validate tooltip position * fix: remove commented code * refactor: simplify arguments to calculatePositionRelativeToCursor() * test: add tests for position utilities * feat: add options controls to example pages * fix: incorrect form value * refactor: simplify code --------- Co-authored-by: Marshall Peterson Co-authored-by: Connor Lamoureux --- examples/examples.css | 4 + examples/vega-examples.html | 32 ++++-- examples/vega-lite-examples.html | 47 ++++++--- src/Handler.ts | 14 +-- src/defaults.ts | 51 +++++++--- src/position.ts | 169 ++++++++++++++++++++++++++++--- test/position.test.ts | 161 +++++++++++++++++++++++++++++ 7 files changed, 424 insertions(+), 54 deletions(-) create mode 100644 test/position.test.ts diff --git a/examples/examples.css b/examples/examples.css index 3c34a7be..514b5769 100644 --- a/examples/examples.css +++ b/examples/examples.css @@ -28,3 +28,7 @@ body { top: 1em; right: 1em; } + +.options { + margin-top: 24px; +} diff --git a/examples/vega-examples.html b/examples/vega-examples.html index 52aca202..2197af6d 100644 --- a/examples/vega-examples.html +++ b/examples/vega-examples.html @@ -1,4 +1,4 @@ - + Tooltip Examples @@ -16,6 +16,13 @@

Vega Tooltip Examples

Back to overview +
+
+ anchor: + + +
+
@@ -35,12 +42,23 @@

Vega Tooltip Examples

await vegaEmbed(id, vgSpec, { tooltip: handler.call }).catch(console.error); } - addVgExample("specs/images.json", "#vis-images"); - addVgExample("specs/arc.json", "#vis-arc"); - addVgExample("specs/choropleth.json", "#vis-choropleth"); - addVgExample("specs/force.json", "#vis-force", { theme: "dark" }); - addVgExample("specs/heatmap.json", "#vis-heatmap"); - addVgExample("specs/voronoi.json", "#vis-voronoi"); + function renderCharts() { + const anchor = document.getElementById("anchor-cursor").checked ? "cursor" : "mark"; + + addVgExample("specs/images.json", "#vis-images", { anchor }); + addVgExample("specs/arc.json", "#vis-arc", { anchor }); + addVgExample("specs/choropleth.json", "#vis-choropleth", { anchor }); + addVgExample("specs/force.json", "#vis-force", { anchor, theme: "dark" }); + addVgExample("specs/heatmap.json", "#vis-heatmap", { anchor }); + addVgExample("specs/voronoi.json", "#vis-voronoi", { anchor }); + } + + // Initial render + renderCharts(); + + // Re-render charts when the checkbox is clicked + document.getElementById("anchor-cursor").addEventListener("change", renderCharts); + document.getElementById("anchor-mark").addEventListener("change", renderCharts); diff --git a/examples/vega-lite-examples.html b/examples/vega-lite-examples.html index 2d83c180..d2e8df60 100644 --- a/examples/vega-lite-examples.html +++ b/examples/vega-lite-examples.html @@ -1,4 +1,4 @@ - + Tooltip Examples @@ -17,6 +17,13 @@

Vega-Lite Tooltip Examples

Back to overview +
+
+ anchor: + + +
+
@@ -50,19 +57,31 @@

Vega-Lite Tooltip Examples

}); } - addVlExample("specs/histogram.json", "#vis-histogram"); - addVlExample("specs/scatter.json", "#vis-scatter"); - addVlExample("specs/bubble_multiple_aggregation.json", "#vis-bubble-multi-aggr"); - addVlExample("specs/trellis_barley.json", "#vis-trellis-barley"); - addVlExample("specs/scatter_binned.json", "#vis-scatter-binned"); - addVlExample("specs/bar.json", "#vis-bar"); - addVlExample("specs/stacked_bar_weather.json", "#vis-stacked-bar"); - addVlExample("specs/bar_layered_transparent.json", "#vis-layered-bar"); - addVlExample("specs/line_color.json", "#vis-color-line"); - addVlExample("specs/overlay_area_short.json", "#vis-overlay-area"); - addVlExample("specs/bar_format_tooltip.json", "#vis-bar-format-tooltip", { - formatTooltip: (value, sanitize) => `The value of ${sanitize(value.a)} is ${sanitize(value.b)}` - }); + function renderCharts() { + const anchor = document.getElementById("anchor-cursor").checked ? "cursor" : "mark"; + + addVlExample("specs/histogram.json", "#vis-histogram", { anchor }); + addVlExample("specs/scatter.json", "#vis-scatter", { anchor }); + addVlExample("specs/bubble_multiple_aggregation.json", "#vis-bubble-multi-aggr", { anchor }); + addVlExample("specs/trellis_barley.json", "#vis-trellis-barley", { anchor }); + addVlExample("specs/scatter_binned.json", "#vis-scatter-binned", { anchor }); + addVlExample("specs/bar.json", "#vis-bar", { anchor }); + addVlExample("specs/stacked_bar_weather.json", "#vis-stacked-bar", { anchor }); + addVlExample("specs/bar_layered_transparent.json", "#vis-layered-bar", { anchor }); + addVlExample("specs/line_color.json", "#vis-color-line", { anchor }); + addVlExample("specs/overlay_area_short.json", "#vis-overlay-area", { anchor }); + addVlExample("specs/bar_format_tooltip.json", "#vis-bar-format-tooltip", { + anchor, + formatTooltip: (value, sanitize) => `The value of ${sanitize(value.a)} is ${sanitize(value.b)}`, + }); + } + + // Initial render + renderCharts(); + + // Re-render charts when the checkbox is clicked + document.getElementById("anchor-cursor").addEventListener("change", renderCharts); + document.getElementById("anchor-mark").addEventListener("change", renderCharts); diff --git a/src/Handler.ts b/src/Handler.ts index ebc9da66..4de651f1 100644 --- a/src/Handler.ts +++ b/src/Handler.ts @@ -1,7 +1,7 @@ import {TooltipHandler} from 'vega-typings'; import {createDefaultStyle, DEFAULT_OPTIONS, Options} from './defaults'; -import {calculatePosition} from './position'; +import {calculatePositionRelativeToCursor, calculatePositionRelativeToMark} from './position'; /** * The tooltip handler class. @@ -54,8 +54,6 @@ export class Handler { * The tooltip handler function. */ private tooltipHandler(handler: any, event: MouseEvent, item: any, value: any) { - // console.log(handler, event, item, value); - // append a div element that we use as a tooltip unless it already exists this.el = document.getElementById(this.options.id); if (!this.el) { @@ -84,12 +82,10 @@ export class Handler { // make the tooltip visible this.el.classList.add('visible', `${this.options.theme}-theme`); - const {x, y} = calculatePosition( - event, - this.el.getBoundingClientRect(), - this.options.offsetX, - this.options.offsetY, - ); + const {x, y} = + this.options.anchor === 'mark' + ? calculatePositionRelativeToMark(handler, event, item, this.el.getBoundingClientRect(), this.options) + : calculatePositionRelativeToCursor(event, this.el.getBoundingClientRect(), this.options); this.el.style.top = `${y}px`; this.el.style.left = `${x}px`; diff --git a/src/defaults.ts b/src/defaults.ts index 64eed164..7979cf3a 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -3,50 +3,52 @@ import defaultStyle from './style'; const EL_ID = 'vg-tooltip-element'; -export const DEFAULT_OPTIONS = { +export type Position = 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export interface Options { /** * X offset. */ - offsetX: 10, + offsetX?: number; /** * Y offset. */ - offsetY: 10, + offsetY?: number; /** * ID of the tooltip element. */ - id: EL_ID, + id?: string; /** * ID of the tooltip CSS style. */ - styleId: 'vega-tooltip-style', + styleId?: string; /** * The name of the theme. You can use the CSS class called [THEME]-theme to style the tooltips. * * There are two predefined themes: "light" (default) and "dark". */ - theme: 'light', + theme?: string; /** * Do not use the default styles provided by Vega Tooltip. If you enable this option, you need to use your own styles. It is not necessary to disable the default style when using a custom theme. */ - disableDefaultStyle: false, + disableDefaultStyle?: boolean; /** * HTML sanitizer function that removes dangerous HTML to prevent XSS. * * This should be a function from string to string. You may replace it with a formatter such as a markdown formatter. */ - sanitize: escapeHTML, + sanitize?: (value: any) => string; /** * The maximum recursion depth when printing objects in the tooltip. */ - maxDepth: 2, + maxDepth?: number; /** * A function to customize the rendered HTML of the tooltip. @@ -55,16 +57,41 @@ export const DEFAULT_OPTIONS = { * @param baseURL The `baseURL` from `options.baseURL` * @returns {string} The returned string will become the `innerHTML` of the tooltip element */ - formatTooltip: formatValue, + formatTooltip?: (value: any, valueToHtml: (value: any) => string, maxDepth: number, baseURL: string) => string; /** * The baseurl to use in image paths. */ + baseURL?: string; + + /** + * The snap reference for the tooltip. + */ + anchor?: 'cursor' | 'mark'; + + /** + * The position of the tooltip relative to the anchor. + * + * Only valid when `anchor` is set to 'mark'. + */ + position?: Position | Position[]; +} + +export const DEFAULT_OPTIONS: Required = { + offsetX: 10, + offsetY: 10, + id: EL_ID, + styleId: 'vega-tooltip-style', + theme: 'light', + disableDefaultStyle: false, + sanitize: escapeHTML, + maxDepth: 2, + formatTooltip: formatValue, baseURL: '', + anchor: 'cursor', + position: ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'], }; -export type Options = Partial; - /** * Escape special HTML characters. * diff --git a/src/position.ts b/src/position.ts index 55c685d7..d18cc0bc 100644 --- a/src/position.ts +++ b/src/position.ts @@ -1,26 +1,171 @@ +import {Bounds} from 'vega-typings'; +import {Options, Position} from './defaults'; + +type MarkBounds = Pick; + /** * Position the tooltip * * @param event The mouse event. * @param tooltipBox - * @param offsetX Horizontal offset. - * @param offsetY Vertical offset. + * @param options Tooltip handler options. */ -export function calculatePosition( +export function calculatePositionRelativeToCursor( event: MouseEvent, tooltipBox: {width: number; height: number}, - offsetX: number, - offsetY: number, + {offsetX, offsetY}: Required, ) { - let x = event.clientX + offsetX; - if (x + tooltipBox.width > window.innerWidth) { - x = +event.clientX - offsetX - tooltipBox.width; + // the possible positions for the tooltip + const positions = getPositions( + {x1: event.clientX, x2: event.clientX, y1: event.clientY, y2: event.clientY}, + tooltipBox, + offsetX, + offsetY, + ); + + // order of positions to try + const postionArr: Position[] = ['bottom-right', 'bottom-left', 'top-right', 'top-left']; + + // test positions till a valid one is found + for (const p of postionArr) { + if (tooltipIsInViewport(positions[p], tooltipBox)) { + return positions[p]; + } } - let y = event.clientY + offsetY; - if (y + tooltipBox.height > window.innerHeight) { - y = +event.clientY - offsetY - tooltipBox.height; + // default to top-left if a valid position is not found + // this is legacy behavior + return positions['top-left']; +} + +/** + * Calculates the position of the tooltip relative to the mark. + * @param handler The handler instance. + * @param event The mouse event. + * @param item The item that the tooltip is being shown for. + * @param tooltipBox Client rect of the tooltip element. + * @param options Tooltip handler options. + * @returns + */ +export function calculatePositionRelativeToMark( + handler: any, + event: MouseEvent, + item: any, + tooltipBox: {width: number; height: number}, + options: Required, +) { + const {position, offsetX, offsetY} = options; + const containerBox = handler._el.getBoundingClientRect(); + const origin = handler._origin; + + // bounds of the mark relative to the viewport + const markBounds = getMarkBounds(containerBox, origin, item); + + // the possible positions for the tooltip + const positions = getPositions(markBounds, tooltipBox, offsetX, offsetY); + + // positions to test + const positionArr = Array.isArray(position) ? position : [position]; + + // test positions till a valid one is found + for (const p of positionArr) { + // verify that the tooltip is in the view and the mouse is not where the tooltip would be + if (tooltipIsInViewport(positions[p], tooltipBox) && !mouseIsOnTooltip(event, positions[p], tooltipBox)) { + return positions[p]; + } } - return {x, y}; + // default to cursor position if a valid position is not found + return calculatePositionRelativeToCursor(event, tooltipBox, options); +} + +// Calculates the bounds of the mark relative to the viewport. +export function getMarkBounds( + containerBox: {left: number; top: number}, + origin: [number, number], + item: any, +): MarkBounds { + // if this is a voronoi mark, we want to use the bounds of the point that voronoi cell represents + const markBounds = item.isVoronoi ? item.datum.bounds : item.bounds; + + let left = containerBox.left + origin[0] + markBounds.x1; + let top = containerBox.top + origin[1] + markBounds.y1; + + // traverse mark groups, summing their offsets to get the total offset + // item bounds are relative to their group so if there are multiple nested groups we need to add them all + let parentItem = item; + while (parentItem.mark.group) { + parentItem = parentItem.mark.group; + if ('x' in parentItem && 'y' in parentItem) { + left += parentItem.x; + top += parentItem.y; + } + } + + const markWidth = markBounds.x2 - markBounds.x1; + const markHeight = markBounds.y2 - markBounds.y1; + + return { + x1: left, + x2: left + markWidth, + y1: top, + y2: top + markHeight, + }; +} + +// Calculates the tooltip xy for each possible position. +export function getPositions( + markBounds: MarkBounds, + tooltipBox: {width: number; height: number}, + offsetX: number, + offsetY: number, +) { + const xc = (markBounds.x1 + markBounds.x2) / 2; + const yc = (markBounds.y1 + markBounds.y2) / 2; + + // x positions + const left = markBounds.x1 - tooltipBox.width - offsetX; + const center = xc - tooltipBox.width / 2; + const right = markBounds.x2 + offsetX; + + // y positions + const top = markBounds.y1 - tooltipBox.height - offsetY; + const middle = yc - tooltipBox.height / 2; + const bottom = markBounds.y2 + offsetY; + + const positions: Record = { + top: {x: center, y: top}, + bottom: {x: center, y: bottom}, + left: {x: left, y: middle}, + right: {x: right, y: middle}, + 'top-left': {x: left, y: top}, + 'top-right': {x: right, y: top}, + 'bottom-left': {x: left, y: bottom}, + 'bottom-right': {x: right, y: bottom}, + }; + return positions; +} + +// Checks if the tooltip would be in the viewport at the given position +export function tooltipIsInViewport(position: {x: number; y: number}, tooltipBox: {width: number; height: number}) { + return ( + position.x >= 0 && + position.y >= 0 && + position.x + tooltipBox.width <= window.innerWidth && + position.y + tooltipBox.height <= window.innerHeight + ); +} + +// Checks if the mouse is within the tooltip area +export function mouseIsOnTooltip( + event: MouseEvent, + position: {x: number; y: number}, + tooltipBox: {width: number; height: number}, +) { + return ( + event.clientX >= position.x && + event.clientX <= position.x + tooltipBox.width && + event.clientY >= position.y && + event.clientY <= position.y + tooltipBox.height + ); } diff --git a/test/position.test.ts b/test/position.test.ts new file mode 100644 index 00000000..c1248676 --- /dev/null +++ b/test/position.test.ts @@ -0,0 +1,161 @@ +import { + calculatePositionRelativeToCursor, + calculatePositionRelativeToMark, + DEFAULT_OPTIONS, + getMarkBounds, + getPositions, + mouseIsOnTooltip, + tooltipIsInViewport, +} from '../src'; + +global.window = Object.create({}); +Object.defineProperty(window, 'innerWidth', {value: 500}); +Object.defineProperty(window, 'innerHeight', {value: 500}); + +const defaultTooltipBox = {width: 100, height: 100}; +const defaultMouseEvent = {clientX: 100, clientY: 100} as MouseEvent; + +const defaultItem = { + isVoronoi: false, + bounds: {x1: 150, x2: 200, y1: 75, y2: 125}, + mark: {}, +}; + +describe('calculatePositionRelativeToCursor()', () => { + test('should return position in bottom right corner of cursor if there is enough space', () => { + const position = calculatePositionRelativeToCursor(defaultMouseEvent, defaultTooltipBox, DEFAULT_OPTIONS); + expect(position).toEqual({x: 110, y: 110}); + }); + test('should return position in top right corner of cursor if there is not space below', () => { + const position = calculatePositionRelativeToCursor( + {...defaultMouseEvent, clientY: 480}, + defaultTooltipBox, + DEFAULT_OPTIONS, + ); + expect(position).toEqual({x: 110, y: 370}); + }); + test('should return position in bottom left corner of cursor if there is not space to the right', () => { + const position = calculatePositionRelativeToCursor( + {...defaultMouseEvent, clientX: 480}, + defaultTooltipBox, + DEFAULT_OPTIONS, + ); + expect(position).toEqual({x: 370, y: 110}); + }); + test('should return position in top left corner of cursor if there is not space below and to the right', () => { + const position = calculatePositionRelativeToCursor( + {...defaultMouseEvent, clientX: 480, clientY: 480}, + defaultTooltipBox, + DEFAULT_OPTIONS, + ); + expect(position).toEqual({x: 370, y: 370}); + }); +}); + +describe('calculatePositionRelativeToMark()', () => { + const handler = {_el: {getBoundingClientRect: () => ({left: 100, top: 100})}, _origin: [0, 0]}; + test('should return position on top if there is enough space', () => { + const position = calculatePositionRelativeToMark( + handler, + defaultMouseEvent, + defaultItem, + defaultTooltipBox, + DEFAULT_OPTIONS, + ); + expect(position).toEqual({x: 225, y: 65}); + }); + test('should use mouse position if there is not room in the viewport for available options', () => { + const position = calculatePositionRelativeToMark( + handler, + defaultMouseEvent, + {...defaultItem, bounds: {x1: 0, x2: 400, y1: 0, y2: 400}}, + {width: 200, height: 200}, + DEFAULT_OPTIONS, + ); + expect(position).toEqual({x: 110, y: 110}); + }); +}); + +describe('getMarkBounds()', () => { + test('should return the bounds of the mark', () => { + expect(getMarkBounds({left: 100, top: 100}, [0, 0], defaultItem)).toEqual({x1: 250, x2: 300, y1: 175, y2: 225}); + expect(getMarkBounds({left: 150, top: 100}, [0, 0], defaultItem)).toEqual({x1: 300, x2: 350, y1: 175, y2: 225}); + expect(getMarkBounds({left: 100, top: 150}, [0, 0], defaultItem)).toEqual({x1: 250, x2: 300, y1: 225, y2: 275}); + expect(getMarkBounds({left: 100, top: 100}, [10, 0], defaultItem)).toEqual({x1: 260, x2: 310, y1: 175, y2: 225}); + expect(getMarkBounds({left: 100, top: 100}, [0, 10], defaultItem)).toEqual({x1: 250, x2: 300, y1: 185, y2: 235}); + }); + test('should use the bounds of the voronoi mark if it is a voronoi mark', () => { + const item = { + ...defaultItem, + datum: {bounds: {x1: 35, x2: 45, y1: 55, y2: 65}}, + }; + expect(getMarkBounds({left: 100, top: 100}, [0, 0], {...item, isVoronoi: true})).toEqual({ + x1: 135, + x2: 145, + y1: 155, + y2: 165, + }); + expect(getMarkBounds({left: 100, top: 100}, [0, 0], {...item, isVoronoi: false})).toEqual({ + x1: 250, + x2: 300, + y1: 175, + y2: 225, + }); + }); + test('should sum the offsets of parent groups', () => { + const item = {...defaultItem, mark: {group: {x: 10, y: 20, mark: {group: {x: 30, y: 40, mark: {}}}}}}; + expect(getMarkBounds({left: 0, top: 0}, [0, 0], defaultItem)).toEqual({x1: 150, x2: 200, y1: 75, y2: 125}); + expect(getMarkBounds({left: 0, top: 0}, [0, 0], item)).toEqual({x1: 190, x2: 240, y1: 135, y2: 185}); + }); +}); + +describe('getPositions()', () => { + test('should calculate all the possible positions for the tooltip', () => { + const markBounds = {x1: 0, x2: 50, y1: 0, y2: 50}; + const tooltipBox = {width: 200, height: 100}; + + const positions = getPositions(markBounds, tooltipBox, 10, 10); + + expect(positions).toHaveProperty('top', {x: -75, y: -110}); + expect(positions).toHaveProperty('bottom', {x: -75, y: 60}); + expect(positions).toHaveProperty('left', {x: -210, y: -25}); + expect(positions).toHaveProperty('right', {x: 60, y: -25}); + expect(positions).toHaveProperty('top-left', {x: -210, y: -110}); + expect(positions).toHaveProperty('top-right', {x: 60, y: -110}); + expect(positions).toHaveProperty('bottom-left', {x: -210, y: 60}); + expect(positions).toHaveProperty('bottom-right', {x: 60, y: 60}); + }); +}); + +describe('tooltipIsInViewport()', () => { + const tooltipBox = {width: 100, height: 100}; + test('should return true if the tooltip is in the viewport', () => { + expect(tooltipIsInViewport({x: 0, y: 0}, tooltipBox)).toBe(true); + expect(tooltipIsInViewport({x: 400, y: 400}, tooltipBox)).toBe(true); + expect(tooltipIsInViewport({x: 0, y: 400}, tooltipBox)).toBe(true); + expect(tooltipIsInViewport({x: 400, y: 0}, tooltipBox)).toBe(true); + }); + test('should return false if the tooltip is not in the viewport', () => { + expect(tooltipIsInViewport({x: -1, y: 0}, tooltipBox)).toBe(false); + expect(tooltipIsInViewport({x: 0, y: -1}, tooltipBox)).toBe(false); + expect(tooltipIsInViewport({x: 401, y: 0}, tooltipBox)).toBe(false); + expect(tooltipIsInViewport({x: 0, y: 401}, tooltipBox)).toBe(false); + }); +}); + +describe('mouseIsOnTooltip()', () => { + const tooltipBox = {width: 100, height: 100}; + test('should return true if the mouse is on the tooltip', () => { + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 0, y: 0}, tooltipBox)).toBe(true); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 50, y: 50}, tooltipBox)).toBe(true); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 100, y: 100}, tooltipBox)).toBe(true); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 100, y: 0}, tooltipBox)).toBe(true); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 0, y: 100}, tooltipBox)).toBe(true); + }); + test('should return false if the mouse is not on the tooltip', () => { + expect(mouseIsOnTooltip(defaultMouseEvent, {x: -1, y: 0}, tooltipBox)).toBe(false); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 0, y: -1}, tooltipBox)).toBe(false); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 101, y: 0}, tooltipBox)).toBe(false); + expect(mouseIsOnTooltip(defaultMouseEvent, {x: 0, y: 101}, tooltipBox)).toBe(false); + }); +});