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},
+ };
+};