-
Notifications
You must be signed in to change notification settings - Fork 45
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
Changes from 10 commits
425276c
e82a302
9007b84
c3a8348
c63c9c3
18e4164
6faccf2
bb649db
aca9ca6
aba5db5
0b93c4c
92219b8
f3adf41
eb18081
ea323fa
47c7ab1
62c97d1
c71a5a6
96ad4da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. | ||
|
@@ -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 GitHub Actions / Test
Check warning on line 56 in src/Handler.ts GitHub Actions / Test
|
||
// 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,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); | ||
} else { | ||
position = calculatePositionRelativeToCursor( | ||
event, | ||
this.el.getBoundingClientRect(), | ||
this.options.offsetX, | ||
this.options.offsetY, | ||
); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could this become
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The two methods can't have the exact same arguments since Unless we want to pass the |
||
|
||
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`; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
||
/** | ||
* 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,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 GitHub Actions / Test
|
||
|
||
/** | ||
* 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>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What was wrong with this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There wasn't anything wrong previously but when adding Originally the types were inferred from the default object. Since the types are inferred, any string default will be inferred as type
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 This is why I flipped the order so we define the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can add |
||
|
||
/** | ||
* Escape special HTML characters. | ||
* | ||
* @param value A value to convert to string and HTML-escape. | ||
*/ | ||
export function escapeHTML(value: any): string { | ||
return String(value).replace(/&/g, '&').replace(/</g, '<'); | ||
} | ||
|
||
|
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 | ||
* | ||
|
@@ -6,7 +11,7 @@ | |
* @param offsetX Horizontal offset. | ||
* @param offsetY Vertical offset. | ||
*/ | ||
export function calculatePosition( | ||
export function calculatePositionRelativeToCursor( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can this method also use |
||
event: MouseEvent, | ||
tooltipBox: {width: number; height: number}, | ||
offsetX: number, | ||
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
); | ||
} |
There was a problem hiding this comment.
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.