Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,8 @@
}
},
() => tooltip != null,
() => config?.tooltipDelay ?? 300,
() => config?.tooltipRecentThreshold ?? 300,
);

function onHover(e: CursorValue | null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,13 @@
);

async function querySelection(px: number, py: number, unitDistance: number): Promise<DataPoint | null> {
return await pointQuery.queryClosestPoint(filter?.predicate?.(clientId), px, py, unitDistance);
return await pointQuery.queryClosestPoint(
filter?.predicate?.(clientId),
px,
py,
unitDistance,
config?.hoverRadius ?? 12,
);
}

async function queryPoints(identifiers: DataPointID[]): Promise<DataPoint[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,13 @@ export interface EmbeddingViewConfig {
* Higher values mean more aggressive culling in dense areas.
* Default: 5 */
downsampleDensityWeight?: number | null;

/** Delay in ms before showing tooltip on hover. Default: 300 */
tooltipDelay?: number | null;

/** Threshold in ms for "recently visible" fast-path for tooltips. Default: 300 */
tooltipRecentThreshold?: number | null;

/** Search radius multiplier for hit detection. Default: 12 */
hoverRadius?: number | null;
}
3 changes: 2 additions & 1 deletion packages/component/src/lib/embedding_view/mosaic_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ export class DataPointQuery {
px: number,
py: number,
unitDistance: number,
hoverRadius: number = 12,
): Promise<DataPoint | null> {
let rMax = unitDistance * 12;
let rMax = unitDistance * hoverRadius;
let { x, y } = this.source;

for (let r of [this.lastDistance, rMax]) {
Expand Down
14 changes: 8 additions & 6 deletions packages/component/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,17 @@ export interface ViewportState {
* If more inputs are provided in the mean time, only the last input will be run.
* At the same time, we make the tooltip appear after delayMS time if the tooltip is not recently shown.
*/
export function throttleTooltip<T, U>(func: (input: T) => Promise<U>, isVisible: () => boolean): (input: T) => void {
export function throttleTooltip<T, U>(
func: (input: T) => Promise<U>,
isVisible: () => boolean,
delayMS: () => number = () => 300,
recentThresholdMS: () => number = () => 300,
): (input: T) => void {
let running = false;
let next: T | undefined = undefined;
let lastVisible: number | undefined = undefined;
let timeout: any | undefined = undefined;

let delayMS = 300;
let recentThresholdMS = 300;

let run = async (input: T) => {
running = true;
try {
Expand All @@ -68,14 +70,14 @@ export function throttleTooltip<T, U>(func: (input: T) => Promise<U>, isVisible:
lastVisible = now;
}
let shouldDelay = true;
if (lastVisible == undefined || now - lastVisible < recentThresholdMS) {
if (lastVisible == undefined || now - lastVisible < recentThresholdMS()) {
shouldDelay = false;
}
if (shouldDelay) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => run(input), delayMS);
timeout = setTimeout(() => run(input), delayMS());
} else {
run(input);
}
Expand Down
93 changes: 93 additions & 0 deletions packages/viewer/test/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { throttleTooltip } from "../../component/src/lib/utils.js";

describe("throttleTooltip", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it("runs immediately on first call (lastVisible is undefined)", async () => {
const func = vi.fn().mockResolvedValue("ok");
const isVisible = vi.fn().mockReturnValue(false);
const throttle = throttleTooltip(
func,
isVisible,
() => 500,
() => 500,
);

throttle("test1");
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith("test1");
});

it("delays execution if tooltip was shown but not recently", async () => {
const func = vi.fn().mockResolvedValue("ok");
let visible = true;
const isVisible = vi.fn(() => visible);

// Config: 500ms delay, 500ms threshold
const throttle = throttleTooltip(
func,
isVisible,
() => 500,
() => 500,
);

// First call, runs immediately and sets lastVisible to now
throttle("test1");
expect(func).toHaveBeenCalledTimes(1);
await Promise.resolve(); // Flush microtask so running becomes false

// Wait 600ms. now - lastVisible = 600 > 500
vi.advanceTimersByTime(600);
visible = false; // not visible anymore, so lastVisible doesn't update on next perform

// Next call, should be delayed because it's after the threshold
throttle("test2");
expect(func).toHaveBeenCalledTimes(1); // Still 1

// If another call comes in during delay, it replaces the pending one
vi.advanceTimersByTime(300); // Wait 300ms
throttle("test3"); // Reset the 500ms delay

vi.advanceTimersByTime(300);
expect(func).toHaveBeenCalledTimes(1); // Still 1 because delay was reset

// Wait remaining 200ms
vi.advanceTimersByTime(200);
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith("test3");
await Promise.resolve(); // Flush microtasks
});

it("runs immediately if tooltip was shown recently", async () => {
const func = vi.fn().mockResolvedValue("ok");
const isVisible = vi.fn().mockReturnValue(true);

// Config: 500ms delay, 500ms threshold
const throttle = throttleTooltip(
func,
isVisible,
() => 500,
() => 500,
);

// First call sets lastVisible
throttle("test1");
expect(func).toHaveBeenCalledTimes(1);
await Promise.resolve(); // flush microtask so running becomes false

// Wait 200ms (less than 500ms threshold)
vi.advanceTimersByTime(200);

// Next call runs immediately
throttle("test2");
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith("test2");
});
});
Loading