Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: anchor tooltip to mark #959

Merged
merged 19 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/vega-lite-examples.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h1>Vega-Lite Tooltip Examples</h1>
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)}`
formatTooltip: (value, sanitize) => `The value of ${sanitize(value.a)} is ${sanitize(value.b)}`,
});
</script>
</body>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"start": "yarn build && concurrently --kill-others -n Server,Rollup 'yarn serve' 'rollup -c -w'",
"pretest": "yarn build:style",
"test": "jest",
"watch": "jest --watch",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can already run yarn test --watch so I don't think we need another command. The main reason I want to not add this is since we have all the other Vega packages and I want to move towards consistency with fewer commands and setup requirements.

"test:inspect": "node --inspect-brk ./node_modules/.bin/jest --runInBand",
"prepare": "yarn copy:data",
"prettierbase": "prettier '*.{css,scss,html}'",
Expand Down
26 changes: 15 additions & 11 deletions src/Handler.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -53,9 +53,7 @@
/**
* The tooltip handler function.
*/
private tooltipHandler(handler: any, event: MouseEvent, item: any, value: any) {

Check warning on line 56 in src/Handler.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type

Check warning on line 56 in src/Handler.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type

Check warning on line 56 in src/Handler.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type
// 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) {
Expand Down Expand Up @@ -84,14 +82,20 @@
// 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,
);
let position: {x: number; y: number};

if (this.options.anchor === 'mark') {
position = calculatePositionRelativeToMark(handler, event, item, this.el.getBoundingClientRect(), this.options);

Check warning on line 88 in src/Handler.ts

View check run for this annotation

Codecov / codecov/patch

src/Handler.ts#L88

Added line #L88 was not covered by tests
} else {
position = calculatePositionRelativeToCursor(
event,
this.el.getBoundingClientRect(),
this.options.offsetX,
this.options.offsetY,
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this become

const position = (this.options.anchor === 'mark' ? calculatePositionRelativeToMark : calculatePositionRelativeToCursor)(event, this.el.getBoundingClientRect(), this.options)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two methods can't have the exact same arguments since calculatePositionRelativeToMark needs the handler and the item.

Unless we want to pass the handler and item into calculatePositionRelativeToCursor but never use them.


this.el.style.top = `${y}px`;
this.el.style.left = `${x}px`;
this.el.style.top = `${position.y}px`;
this.el.style.left = `${position.x}px`;
}
}
58 changes: 46 additions & 12 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,59 @@

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;

/**
* Set the offset of the tooltip in the direction that it is being anchored.
*
* Example: bottom left would offset the tooltip to the bottom left by the offset value in pixels.
*/
// offset?: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leftover?


/**
* 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;

Check warning on line 53 in src/defaults.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type

/**
* The maximum recursion depth when printing objects in the tooltip.
*/
maxDepth: 2,
maxDepth?: number;

/**
* A function to customize the rendered HTML of the tooltip.
Expand All @@ -55,22 +64,47 @@
* @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;

Check warning on line 67 in src/defaults.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type

Check warning on line 67 in src/defaults.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type

/**
* 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<Options> = {
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<typeof DEFAULT_OPTIONS>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was wrong with this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There wasn't anything wrong previously but when adding anchor and position, the method didn't work well.

Originally the types were inferred from the default object. Since the types are inferred, any string default will be inferred as type string. This worked great for all the previous properties of Options.

anchor and position are both string unions. So there is a set list of acceptable strings that can be provided. If we let the types be inferred by the default object, then the type for these would be inferred as string instead of cursor | mark etc.

The advantage of these two using a string union is intelliSense and general type checking. If a consumer is using TS, then they will get an error if they set anchor = 'mouse'. And all consumer (TS of JS) should get intelliSense suggestions as long intelliSense is enabled.

image

This is why I flipped the order so we define the Options type first and then define the DEFAULT_OPTIONS and set it's type to Options.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add as const or as 'cursor' | 'mark to the object, I think, to make the type stricter. But I don't feel strongly about flipping this so no need to change.


/**
* Escape special HTML characters.
*
* @param value A value to convert to string and HTML-escape.
*/
export function escapeHTML(value: any): string {

Check warning on line 107 in src/defaults.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;');
}

Expand Down
138 changes: 137 additions & 1 deletion src/position.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {Bounds} from 'vega-typings';
import {Options, Position} from './defaults';

type MarkBounds = Pick<Bounds, 'x1' | 'x2' | 'y1' | 'y2'>;

/**
* Position the tooltip
*
Expand All @@ -6,7 +11,7 @@
* @param offsetX Horizontal offset.
* @param offsetY Vertical offset.
*/
export function calculatePosition(
export function calculatePositionRelativeToCursor(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this method also use tooltipIsInViewport?

event: MouseEvent,
tooltipBox: {width: number; height: number},
offsetX: number,
Expand All @@ -24,3 +29,134 @@

return {x, y};
}

/**
* 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},
{position, offsetX, offsetY}: Required<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];
}
}

// default to cursor position if a valid position is not found
return calculatePositionRelativeToCursor(event, tooltipBox, offsetX, offsetY);
}

// 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<Position, {x: number; y: number}> = {
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
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
function mouseIsOnTooltip(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's write a unit test for this.

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

Check warning on line 160 in src/position.ts

View check run for this annotation

Codecov / codecov/patch

src/position.ts#L158-L160

Added lines #L158 - L160 were not covered by tests
);
}
Loading
Loading