diff --git a/demo/examples/tooltip-tracking-strategies.html b/demo/examples/tooltip-tracking-strategies.html new file mode 100644 index 00000000..05517141 --- /dev/null +++ b/demo/examples/tooltip-tracking-strategies.html @@ -0,0 +1,136 @@ + + + Yagr + + + + + + +

Tooltip tracking strategies

+
+
+
+
+
+
+ + + + diff --git a/src/YagrCore/plugins/tooltip/tooltip.ts b/src/YagrCore/plugins/tooltip/tooltip.ts index 60454529..c2f6d038 100644 --- a/src/YagrCore/plugins/tooltip/tooltip.ts +++ b/src/YagrCore/plugins/tooltip/tooltip.ts @@ -1,6 +1,6 @@ /* eslint-disable complexity, no-nested-ternary */ -import uPlot, {Series} from 'uplot'; +import uPlot, {Series, TypedArray} from 'uplot'; import {CursorOptions} from '../cursor/cursor'; import placementFn from './placement'; @@ -10,7 +10,7 @@ import {DataSeries, ProcessingInterpolation, YagrPlugin} from '../../types'; import {TOOLTIP_Y_OFFSET, TOOLTIP_X_OFFSET, TOOLTIP_DEFAULT_MAX_LINES, DEFAULT_Y_SCALE} from '../../defaults'; -import {findInRange, findDataIdx, findSticky, px, isNil} from '../../utils/common'; +import {findDataIdx, findSticky, px, isNil, findInRange} from '../../utils/common'; import { TooltipOptions, TooltipRow, @@ -25,7 +25,7 @@ import { } from './types'; import {renderTooltip} from './render'; -import {getOptionValue} from './utils'; +import {findClosestLines, getOptionValue} from './utils'; // eslint-disable-next-line complexity const findValue = ( @@ -397,19 +397,58 @@ class YagrTooltip { section.rows.push(rowData); } - if (getOptionValue(opts.highlight, scale) && section.rows.length) { - const tracking = getOptionValue(opts.tracking, scale); + if (getOptionValue(opts.highlight, scale) && section.rows.length && !state.pinned) { let activeIndex: number | null = 0; - if (tracking === 'area') { - activeIndex = findInRange( - section, - cursorValue, - getOptionValue(opts.stickToRanges, scale), - ); - } else if (tracking === 'sticky') { - activeIndex = findSticky(section, cursorValue); - } else if (typeof tracking === 'function') { - activeIndex = tracking(section, cursorValue); + const tracking = getOptionValue(opts.tracking, scale); + const areaSeries = this.yagr.series.filter( + (_, si) => si === 0 || serieIndicies.includes(si), + ) as TypedArray[]; + + switch (tracking) { + case 'area': + activeIndex = findInRange( + section, + cursorValue, + getOptionValue(opts.stickToRanges, scale), + ); + break; + case 'sticky': + activeIndex = findSticky(section, cursorValue); + break; + case 'area-closest': { + const lines = findClosestLines({ + x: u.posToVal(left, 'x'), + y: u.posToVal(top, scale), + series: areaSeries, + }); + + if (u.posToVal(top, scale) >= 0) { + activeIndex = lines.higher.index ?? lines.lower.index; + } else { + activeIndex = lines.lower.index ?? lines.higher.index; + } + + break; + } + case 'line-closest': { + const lines = findClosestLines({ + x: u.posToVal(left, 'x'), + y: u.posToVal(top, scale), + series: areaSeries, + }); + + if (lines.higher.distance < lines.lower.distance) { + activeIndex = lines.higher.index; + } else { + activeIndex = lines.lower.index; + } + + break; + } + default: + if (typeof tracking === 'function') { + activeIndex = tracking(section, cursorValue); + } } if (activeIndex !== null) { diff --git a/src/YagrCore/plugins/tooltip/types.ts b/src/YagrCore/plugins/tooltip/types.ts index 412f1bea..6edbde48 100644 --- a/src/YagrCore/plugins/tooltip/types.ts +++ b/src/YagrCore/plugins/tooltip/types.ts @@ -3,7 +3,11 @@ import Yagr from '../../index'; export type TrackingOptions = /** Tracks serie only if mouse hovered on series' area */ | 'area' + /** Tracks mouse to closest area */ + | 'area-closest' /** Tracks mouse to closest line */ + | 'line-closest' + /** Tracks mouse to closest line near closest timeline */ | 'sticky' /** Custom tracking function */ | ((s: TooltipSection, y: number) => number | null); diff --git a/src/YagrCore/plugins/tooltip/utils.ts b/src/YagrCore/plugins/tooltip/utils.ts index 229b505c..753bc66c 100644 --- a/src/YagrCore/plugins/tooltip/utils.ts +++ b/src/YagrCore/plugins/tooltip/utils.ts @@ -1,3 +1,5 @@ +import {AlignedData} from 'uplot'; + export function getOptionValue(option: T | {[key in string]: T}, scale: string): T { return (typeof option === 'object' ? (option as {[key in string]: T})[scale] : option) as T; } @@ -8,3 +10,65 @@ export function escapeHTML(html: string) { elem.innerText = html; return elem.innerHTML; } + +type FindClosestAreaOptions = { + /** cursor X */ + x: number; + /** cursor Y */ + y: number; + series: AlignedData; +}; + +export const findClosestLines = ({x, y, series}: FindClosestAreaOptions) => { + const [timeline, ...areas] = series as number[][]; + + let i1 = 0, + i2 = 0; + + // finding current timeline + timeline.forEach((t, i) => { + if (i1 || i2) { + return; + } + if (t === x) { + i1 = i2 = i; + return; + } + if (t > x) { + i1 = i - 1; + i2 = i; + } + }); + + let minLower = Number.MAX_VALUE, + minHigher = Number.MAX_VALUE; + let iLower: number | null = null, + iHigher: number | null = null; + + const x1 = timeline[i1]; + const x2 = timeline[i2]; + + areas + .map((a) => [a[i1], a[i2]]) + .forEach(([y1, y2], i) => { + // finding intersection between line and vertical X = x + const k = (y2 - y1) / (x2 - x1); + const b = y1 - k * x1; + const yInter = k * x + b; + + const distance = Math.abs(y - yInter); + + if (yInter < y && distance <= minLower) { + minLower = distance; + iLower = i; + } else if (yInter >= y && distance <= minHigher) { + minHigher = distance; + iHigher = i; + } + }); + + return { + lower: {index: iLower, distance: minLower}, + higher: {index: iHigher, distance: minHigher}, + }; +};