diff --git a/.changeset/cyan-melons-pinch.md b/.changeset/cyan-melons-pinch.md new file mode 100644 index 000000000..e87e5cd9d --- /dev/null +++ b/.changeset/cyan-melons-pinch.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(TransformContext): Add two-finger pinch-to-zoom (and pan) support on touch devices, along with a new `pinch` option to disable it diff --git a/docs/src/content/guides/transform.md b/docs/src/content/guides/transform.md index ebe2965a7..d4775af0f 100644 --- a/docs/src/content/guides/transform.md +++ b/docs/src/content/guides/transform.md @@ -153,13 +153,28 @@ When set, scroll events without the key held are ignored (no `preventDefault`), - **Drag** — pan (click and drag to move the view) - **Double-click** — zoom in 2x at the click point - **Shift + double-click** — zoom out 0.5x -- **Pinch-to-zoom** — detected as ctrl+wheel events, always zooms regardless of scroll mode +- **Trackpad pinch** — detected as ctrl+wheel events, always zooms regardless of scroll mode +- **Touch pinch** — two fingers zoom and pan together (see below) - **Trackpad horizontal scroll** — pans horizontally in domain mode The `clickDistance` option (default: `10` pixels) sets the threshold before a pointer movement is treated as a drag rather than a click. Set `disablePointer: true` to disable all pointer-based interactions (useful when using programmatic zoom only). +### Pinch to zoom (touch) + +Two-finger pinch gestures are supported on touch devices and are enabled by default. The scale follows the change in distance between the two fingers, while the midpoint between them stays anchored to the same point in the chart, so zooming and panning happen together in a single gesture. + +Set `pinch: false` to disable it (drag-to-pan and trackpad pinch remain available): + +```svelte + +``` + +In `domain` mode with `axis: 'x'` or `axis: 'y'`, only the distance along the active axis is used, so a horizontal pinch zooms the x domain without vertical finger movement affecting it. + +Adding or removing a finger mid-gesture re-anchors the gesture instead of jumping — lifting one finger continues as a drag with the remaining finger. Inertia is not applied when a pinch ends. + ### Brush integration Combining `brush` with `transform` enables brush-to-zoom: the user draws a selection, then the chart zooms to that domain range. @@ -515,6 +530,7 @@ It supports placement (`'top-left'`, `'top-right'`, `'bottom-left'`, etc.), orie | Dynamic data loading | Derive data from `context.xDomain` visible range | [pan-zoom-dynamic-data](/docs/components/LineChart/pan-zoom-dynamic-data) | | Drag inertia | `inertia: true` with `motion: 'spring'` | [transform-globe-inertia](/docs/components/GeoPath/transform-globe-inertia) | | Require key to scroll | `scrollActivationKey: 'meta'` | [scroll-activation-key](/docs/components/TransformContext/scroll-activation-key) | +| Disable touch pinch | `pinch: false` | enabled by default for all modes | ## API reference diff --git a/packages/layerchart/src/lib/components/TransformContext.svelte b/packages/layerchart/src/lib/components/TransformContext.svelte index a4dccfce1..03cc1fbdc 100644 --- a/packages/layerchart/src/lib/components/TransformContext.svelte +++ b/packages/layerchart/src/lib/components/TransformContext.svelte @@ -33,6 +33,7 @@ onpointermove = () => {}, ontouchmove = () => {}, onpointerup = () => {}, + onpointercancel = () => {}, ondblclick = () => {}, onclickcapture = () => {}, ref: refProp = $bindable(), @@ -54,6 +55,7 @@ translateExtent, constrain, inertia, + pinch, scrollActivationKey, ...restProps }: TransformContextProps = $props(); @@ -75,6 +77,7 @@ translateExtent, constrain, inertia, + pinch, scrollActivationKey, }; @@ -158,6 +161,10 @@ } }); + $effect.pre(() => { + transformState.pinch = pinch ?? true; + }); + $effect.pre(() => { transformState.scrollActivationKey = scrollActivationKey; }); @@ -180,6 +187,11 @@ transformState.onPointerUp(e); } + function onPointerCancel(e: PointerEvent & { currentTarget: HTMLElement }) { + onpointercancel?.(e); + transformState.onPointerCancel(e); + } + function onClick(e: MouseEvent & { currentTarget: HTMLElement }) { onclickcapture?.(e); if (transformState.dragging) { @@ -213,8 +225,10 @@ } }} onpointerup={onPointerUp} + onpointercancel={onPointerCancel} ondblclick={onDoubleClick} onclickcapture={onClick} + style:touch-action={mode && mode !== 'none' && !disablePointer ? 'none' : undefined} class={['lc-transform-context', className]} bind:this={ref} {...restProps} diff --git a/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts b/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts index 90c4375e8..5bb5cec35 100644 --- a/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts +++ b/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts @@ -208,4 +208,263 @@ describe('TransformContext', () => { }); }); }); + + describe('pinch to zoom', () => { + /** Render the harness and return the transform state along with pointer event helpers */ + async function setup(transform: Record) { + let chartContext: any; + + render(TransformTestHarness, { + chartProps: { height: 300, transform }, + oncontext: (ctx: any) => { + chartContext = ctx; + }, + }); + + await vi.waitFor(() => expect(chartContext).toBeDefined()); + + // TransformContext is lazy-loaded, so wait for it to render + const element = await vi.waitFor(() => { + const el = document.querySelector('.lc-transform-context'); + expect(el).not.toBeNull(); + return el!; + }); + + const rect = element.getBoundingClientRect(); + function dispatch(type: string, pointerId: number, x: number, y: number) { + element.dispatchEvent( + new PointerEvent(type, { + pointerId, + pointerType: 'touch', + clientX: rect.left + x, + clientY: rect.top + y, + bubbles: true, + cancelable: true, + }) + ); + } + + return { + get context() { + return chartContext; + }, + get transform() { + return chartContext.transform; + }, + dispatch, + }; + } + + it('should scale by the change in distance between two pointers', async () => { + const { transform, dispatch } = await setup({ mode: 'canvas' }); + + expect(transform.scale).toBe(1); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointerdown', 2, 200, 100); + expect(transform.pinching).toBe(true); + + // Double the distance between pointers (100 -> 200) + dispatch('pointermove', 2, 300, 100); + expect(transform.scale).toBe(2); + + // Halve the distance from the gesture start (100 -> 50) + dispatch('pointermove', 2, 150, 100); + expect(transform.scale).toBe(0.5); + + dispatch('pointerup', 2, 150, 100); + dispatch('pointerup', 1, 100, 100); + expect(transform.pinching).toBe(false); + expect(transform.dragging).toBe(false); + }); + + it('should keep the pinch midpoint anchored while zooming', async () => { + const { context, transform, dispatch } = await setup({ mode: 'canvas' }); + const { padding } = context; + + dispatch('pointerdown', 1, 100, 200); + dispatch('pointerdown', 2, 300, 200); + // Midpoint (200, 200), distance 200 + dispatch('pointermove', 1, 50, 200); + dispatch('pointermove', 2, 350, 200); + // Midpoint (200, 200), distance 300 + + expect(transform.scale).toBe(1.5); + // The point under the midpoint should remain under the midpoint after scaling + // (chart coordinates are relative to padding) + expect(transform.translate.x).toBeCloseTo((200 - padding.left) * (1 - 1.5), 5); + expect(transform.translate.y).toBeCloseTo((200 - padding.top) * (1 - 1.5), 5); + }); + + it('should pan when both pointers move together', async () => { + const { transform, dispatch } = await setup({ mode: 'canvas' }); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointerdown', 2, 200, 100); + + dispatch('pointermove', 1, 130, 150); + dispatch('pointermove', 2, 230, 150); + + expect(transform.scale).toBe(1); + expect(transform.translate).toEqual({ x: 30, y: 50 }); + }); + + it('should respect scaleExtent', async () => { + const { transform, dispatch } = await setup({ mode: 'canvas', scaleExtent: [1, 2] }); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointerdown', 2, 200, 100); + + dispatch('pointermove', 2, 500, 100); // 4x + expect(transform.scale).toBe(2); + + dispatch('pointermove', 2, 120, 100); // 0.2x + expect(transform.scale).toBe(1); + }); + + it('should continue dragging with the remaining pointer without jumping', async () => { + const { transform, dispatch } = await setup({ mode: 'canvas' }); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointerdown', 2, 200, 100); + dispatch('pointermove', 2, 300, 100); + + const translateAfterPinch = { ...transform.translate }; + + // Release the second pointer - the first should continue panning from its current position + dispatch('pointerup', 2, 300, 100); + expect(transform.pinching).toBe(false); + + dispatch('pointermove', 1, 140, 100); + expect(transform.translate.x).toBeCloseTo(translateAfterPinch.x + 40, 5); + expect(transform.scale).toBe(2); + }); + + it('should release cancelled pointers', async () => { + const { transform, dispatch } = await setup({ mode: 'canvas' }); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointercancel', 1, 100, 100); + expect(transform.moving).toBe(false); + + // Stale pointer should not be treated as part of a new gesture + dispatch('pointerdown', 2, 100, 100); + expect(transform.pinching).toBe(false); + }); + + it('should only use the horizontal distance in domain mode with `axis: x`', async () => { + let chartContext: any; + + render(TransformTestHarness, { + chartProps: { + height: 300, + data: [ + { date: 0, value: 1 }, + { date: 10, value: 2 }, + ], + x: 'date', + y: 'value', + transform: { mode: 'domain' as const, axis: 'x' as const }, + }, + oncontext: (ctx: any) => { + chartContext = ctx; + }, + }); + + await vi.waitFor(() => expect(chartContext).toBeDefined()); + const element = await vi.waitFor(() => { + const el = document.querySelector('.lc-transform-context'); + expect(el).not.toBeNull(); + return el!; + }); + + const rect = element.getBoundingClientRect(); + const dispatch = (type: string, pointerId: number, x: number, y: number) => + element.dispatchEvent( + new PointerEvent(type, { + pointerId, + pointerType: 'touch', + clientX: rect.left + x, + clientY: rect.top + y, + bubbles: true, + cancelable: true, + }) + ); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointerdown', 2, 200, 150); + + // Doubling the horizontal distance doubles the scale, regardless of vertical movement + dispatch('pointermove', 2, 300, 250); + + expect(chartContext.transform.scale).toBe(2); + expect(chartContext.transform.translate.y).toBe(0); + }); + + it('should not show the tooltip while pinching', async () => { + let chartContext: any; + + render(TransformTestHarness, { + chartProps: { + height: 300, + data: [ + { date: 0, value: 1 }, + { date: 10, value: 2 }, + ], + x: 'date', + y: 'value', + transform: { mode: 'domain' as const, axis: 'x' as const }, + tooltipContext: { mode: 'bisect-x' as const }, + }, + oncontext: (ctx: any) => { + chartContext = ctx; + }, + }); + + await vi.waitFor(() => expect(chartContext).toBeDefined()); + const { transformEl, tooltipEl } = await vi.waitFor(() => { + const transformEl = document.querySelector('.lc-transform-context'); + const tooltipEl = document.querySelector('.lc-tooltip-context'); + expect(transformEl).not.toBeNull(); + expect(tooltipEl).not.toBeNull(); + return { transformEl: transformEl!, tooltipEl: tooltipEl! }; + }); + + const rect = transformEl.getBoundingClientRect(); + const dispatch = (el: HTMLElement, type: string, pointerId: number, x: number, y: number) => + el.dispatchEvent( + new PointerEvent(type, { + pointerId, + pointerType: 'touch', + clientX: rect.left + x, + clientY: rect.top + y, + bubbles: true, + cancelable: true, + }) + ); + + // Sanity check - the tooltip shows on pointer move + dispatch(tooltipEl, 'pointermove', 1, 100, 100); + await vi.waitFor(() => expect(chartContext.tooltipState.data).not.toBeNull()); + + // Start a pinch (events bubble from the tooltip area up to the transform context) + dispatch(tooltipEl, 'pointerdown', 1, 100, 100); + dispatch(tooltipEl, 'pointerdown', 2, 200, 100); + expect(chartContext.transform.pinching).toBe(true); + + dispatch(tooltipEl, 'pointermove', 2, 300, 100); + await vi.waitFor(() => expect(chartContext.tooltipState.data).toBeNull()); + }); + + it('should not pinch when disabled', async () => { + const { transform, dispatch } = await setup({ mode: 'canvas', pinch: false }); + + dispatch('pointerdown', 1, 100, 100); + dispatch('pointerdown', 2, 200, 100); + dispatch('pointermove', 2, 300, 100); + + expect(transform.pinching).toBe(false); + expect(transform.scale).toBe(1); + }); + }); }); diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts index 86194a197..5285b494a 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import LineChart from '../charts/LineChart/LineChart.svelte'; @@ -32,6 +32,40 @@ function triggerTooltip(el: Element, position?: { clientX: number; clientY: numb el.dispatchEvent(new PointerEvent('pointermove', eventInit)); } +/** + * Trigger the tooltip and wait for `assertion` to pass, re-dispatching the pointer events on + * every poll. + * + * Test files share a single browser, and therefore a single real cursor. When a chart mounts + * underneath that cursor the browser fires its own `pointerenter`, which shows the tooltip at the + * cursor position instead of the position under test. Under load that real event can arrive after + * the synthetic ones, so re-dispatching keeps the tooltip at the position being asserted. + */ +async function waitForTooltip( + el: Element, + position: { clientX: number; clientY: number } | undefined, + assertion: () => void +) { + await vi.waitFor(() => { + triggerTooltip(el, position); + assertion(); + }); +} + +/** + * The tooltip root is portaled to `document.body`, outside the render container, so a root whose + * fade transition outlives its test would be the first match in the body. Clear any leftovers + * before each test and read the most recently portaled root. + */ +beforeEach(() => { + document.body.querySelectorAll('.lc-tooltip-root').forEach((el) => el.remove()); +}); + +function getTooltipRoot(target: ParentNode = document.body) { + const roots = target.querySelectorAll('.lc-tooltip-root'); + return roots.length ? roots[roots.length - 1] : null; +} + describe('Tooltip', () => { describe('portal', () => { it('should portal tooltip to body by default', async () => { @@ -41,11 +75,9 @@ describe('Tooltip', () => { const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; await expect.element(tooltipCtx).toBeInTheDocument(); - triggerTooltip(tooltipCtx); - - await vi.waitFor(() => { + await waitForTooltip(tooltipCtx, undefined, () => { // Tooltip root should be portaled to body (outside the chart container) - const tooltipInBody = document.body.querySelector('.lc-tooltip-root'); + const tooltipInBody = getTooltipRoot(); expect(tooltipInBody).not.toBeNull(); // Should use fixed positioning when portaled @@ -64,11 +96,9 @@ describe('Tooltip', () => { const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; await expect.element(tooltipCtx).toBeInTheDocument(); - triggerTooltip(tooltipCtx); - - await vi.waitFor(() => { + await waitForTooltip(tooltipCtx, undefined, () => { // Tooltip root should be inside the chart container - const tooltipInContainer = container.querySelector('.lc-tooltip-root'); + const tooltipInContainer = getTooltipRoot(container); expect(tooltipInContainer).not.toBeNull(); // Should use absolute positioning when not portaled @@ -93,10 +123,8 @@ describe('Tooltip', () => { const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; await expect.element(tooltipCtx).toBeInTheDocument(); - triggerTooltip(tooltipCtx); - - await vi.waitFor(() => { - const tooltipInTarget = portalTarget.querySelector('.lc-tooltip-root'); + await waitForTooltip(tooltipCtx, undefined, () => { + const tooltipInTarget = getTooltipRoot(portalTarget); expect(tooltipInTarget).not.toBeNull(); }); } finally { @@ -111,10 +139,8 @@ describe('Tooltip', () => { const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; await expect.element(tooltipCtx).toBeInTheDocument(); - triggerTooltip(tooltipCtx); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root'); + await waitForTooltip(tooltipCtx, undefined, () => { + const tooltipRoot = getTooltipRoot(); expect(tooltipRoot).not.toBeNull(); // Should contain tooltip content (items from default tooltip) @@ -130,10 +156,8 @@ describe('Tooltip', () => { const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; await expect.element(tooltipCtx).toBeInTheDocument(); - triggerTooltip(tooltipCtx); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; + await waitForTooltip(tooltipCtx, undefined, () => { + const tooltipRoot = getTooltipRoot()!; expect(tooltipRoot).not.toBeNull(); // Should have valid pixel positions (not NaN or empty) @@ -156,22 +180,21 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the right edge of the container - triggerTooltip(tooltipCtx, { - clientX: ctxRect.right - 5, - clientY: ctxRect.top + ctxRect.height / 2, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipLeft = parseFloat(tooltipRoot.style.left); - // Tooltip should be positioned to the LEFT of the pointer (flipped), - // so its left edge should be less than the pointer position - expect(tooltipLeft).toBeLessThan(ctxRect.right - 5); - // And specifically, the tooltip's right edge should not exceed the container - expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(ctxRect.right + 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.right - 5, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + // Tooltip should be positioned to the LEFT of the pointer (flipped), + // so its left edge should be less than the pointer position + expect(tooltipLeft).toBeLessThan(ctxRect.right - 5); + // And specifically, the tooltip's right edge should not exceed the container + expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(ctxRect.right + 1); + } + ); }); it('should flip tooltip right when pointer is near the left edge', async () => { @@ -187,20 +210,19 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the left edge of the container - triggerTooltip(tooltipCtx, { - clientX: ctxRect.left + 5, - clientY: ctxRect.top + ctxRect.height / 2, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipLeft = parseFloat(tooltipRoot.style.left); - // Tooltip should be positioned to the RIGHT of the pointer (flipped), - // so its left edge should be >= the container's left edge - expect(tooltipLeft).toBeGreaterThanOrEqual(ctxRect.left - 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.left + 5, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + // Tooltip should be positioned to the RIGHT of the pointer (flipped), + // so its left edge should be >= the container's left edge + expect(tooltipLeft).toBeGreaterThanOrEqual(ctxRect.left - 1); + } + ); }); it('should flip tooltip up when pointer is near the bottom edge', async () => { @@ -213,20 +235,19 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the bottom edge of the container - triggerTooltip(tooltipCtx, { - clientX: ctxRect.left + ctxRect.width / 2, - clientY: ctxRect.bottom - 5, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipTop = parseFloat(tooltipRoot.style.top); - // Tooltip should be positioned ABOVE the pointer (flipped), - // so its bottom edge should not exceed the container - expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(ctxRect.bottom + 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.bottom - 5 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipTop = parseFloat(tooltipRoot.style.top); + // Tooltip should be positioned ABOVE the pointer (flipped), + // so its bottom edge should not exceed the container + expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(ctxRect.bottom + 1); + } + ); }); it('should flip tooltip down when pointer is near the top edge', async () => { @@ -242,20 +263,19 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the top edge of the container - triggerTooltip(tooltipCtx, { - clientX: ctxRect.left + ctxRect.width / 2, - clientY: ctxRect.top + 5, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipTop = parseFloat(tooltipRoot.style.top); - // Tooltip should be positioned BELOW the pointer (flipped), - // so its top edge should be >= the container's top - expect(tooltipTop).toBeGreaterThanOrEqual(ctxRect.top - 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.top + 5 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipTop = parseFloat(tooltipRoot.style.top); + // Tooltip should be positioned BELOW the pointer (flipped), + // so its top edge should be >= the container's top + expect(tooltipTop).toBeGreaterThanOrEqual(ctxRect.top - 1); + } + ); }); }); @@ -273,19 +293,18 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the right edge of the container - triggerTooltip(tooltipCtx, { - clientX: ctxRect.right - 5, - clientY: ctxRect.top + ctxRect.height / 2, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipLeft = parseFloat(tooltipRoot.style.left); - // Tooltip should not overflow the right side of the viewport - expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(window.innerWidth + 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.right - 5, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + // Tooltip should not overflow the right side of the viewport + expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(window.innerWidth + 1); + } + ); }); it('should flip tooltip up when it would overflow the bottom of the viewport', async () => { @@ -301,19 +320,18 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the bottom edge of the container - triggerTooltip(tooltipCtx, { - clientX: ctxRect.left + ctxRect.width / 2, - clientY: ctxRect.bottom - 5, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipTop = parseFloat(tooltipRoot.style.top); - // Tooltip should not overflow the bottom of the viewport - expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(window.innerHeight + 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.left + ctxRect.width / 2, clientY: ctxRect.bottom - 5 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipTop = parseFloat(tooltipRoot.style.top); + // Tooltip should not overflow the bottom of the viewport + expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(window.innerHeight + 1); + } + ); }); }); @@ -331,22 +349,21 @@ describe('Tooltip', () => { const ctxRect = tooltipCtx.getBoundingClientRect(); // Trigger near the right edge — tooltip should NOT flip - triggerTooltip(tooltipCtx, { - clientX: ctxRect.right - 5, - clientY: ctxRect.top + ctxRect.height / 2, - }); - - await vi.waitFor(() => { - const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement; - expect(tooltipRoot).not.toBeNull(); - - const tooltipLeft = parseFloat(tooltipRoot.style.left); - // With default anchor='top-left', tooltip is placed to the right of pointer. - // Since contained=false, the tooltip left should be near/past the pointer x - // (i.e., it doesn't flip like contained="container" would) - const pointerViewportX = ctxRect.right - 5; - expect(tooltipLeft).toBeGreaterThanOrEqual(pointerViewportX - 1); - }); + await waitForTooltip( + tooltipCtx, + { clientX: ctxRect.right - 5, clientY: ctxRect.top + ctxRect.height / 2 }, + () => { + const tooltipRoot = getTooltipRoot()!; + expect(tooltipRoot).not.toBeNull(); + + const tooltipLeft = parseFloat(tooltipRoot.style.left); + // With default anchor='top-left', tooltip is placed to the right of pointer. + // Since contained=false, the tooltip left should be near/past the pointer x + // (i.e., it doesn't flip like contained="container" would) + const pointerViewportX = ctxRect.right - 5; + expect(tooltipLeft).toBeGreaterThanOrEqual(pointerViewportX - 1); + } + ); }); }); }); diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte index 1cb0da6c7..b4b1753f9 100644 --- a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte +++ b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte @@ -245,6 +245,12 @@ } function showTooltip(e: PointerEvent | MouseEvent | TouchEvent, tooltipData?: any) { + if (isTransforming) { + // Pointer gesture (drag/pinch) owns the pointer - do not show/update the tooltip + hideTooltip(); + return; + } + // Cancel hiding tooltip if from previous event loop if (hideTimeoutId) { clearTimeout(hideTimeoutId); @@ -666,7 +672,25 @@ ['bisect-x', 'bisect-y', 'bisect-band', 'quadtree', 'quadtree-x', 'quadtree-y'].includes(mode) ); + /** + * Whether a transform pointer gesture (drag or pinch) is in progress. Pointer capture normally + * retargets events to `TransformContext` mid-gesture, but events which arrive before capture is + * established (ex. each new pointer of a pinch) would otherwise show/update the tooltip. + */ + const isTransforming = $derived( + ctx.transformState?.dragging === true || ctx.transformState?.pinching === true + ); + + $effect(() => { + // Hide a tooltip shown before the gesture started (ex. first finger of a pinch) + if (isTransforming) { + hideTooltip(); + } + }); + function onPointerEnter(e: PointerEvent | MouseEvent | TouchEvent) { + if (isTransforming) return; + tooltipState.isHoveringTooltipArea = true; if (triggerPointerEvents) { showTooltip(e); @@ -674,6 +698,8 @@ } function onPointerMove(e: PointerEvent | MouseEvent | TouchEvent) { + if (isTransforming) return; + if (triggerPointerEvents) { showTooltip(e); } diff --git a/packages/layerchart/src/lib/states/transform.svelte.ts b/packages/layerchart/src/lib/states/transform.svelte.ts index aa1ffeef3..9c131fe07 100644 --- a/packages/layerchart/src/lib/states/transform.svelte.ts +++ b/packages/layerchart/src/lib/states/transform.svelte.ts @@ -55,6 +55,13 @@ export type TransformStateOptions = { /** Enable inertia (momentum) after drag release. Pass `true` for defaults or an options object. */ inertia?: boolean | InertiaOptions; + /** + * Enable two-finger pinch-to-zoom (and simultaneous pan) on touch devices. + * Trackpad pinch (wheel + ctrl) is always supported when `scrollMode` is not `none`. + * Default: `true` + */ + pinch?: boolean; + /** Require a modifier key to be held for scroll/wheel to activate zoom/pan. Default: no key required. */ scrollActivationKey?: ScrollActivationKey; @@ -106,10 +113,12 @@ export class TransformState { maxVelocity: number; velocityWindow: number; }; + pinch: boolean; // State pointerDown = $state(false); dragging = $state(false); + pinching = $state(false); scrollMode = $state('none'); startPoint = $state({ x: 0, y: 0 }); startTranslate = $state({ x: 0, y: 0 }); @@ -117,6 +126,17 @@ export class TransformState { // Velocity tracking for inertia private _pointerSamples: { x: number; y: number; t: number }[] = []; + // Active pointers (by `pointerId`), used to detect multi-touch pinch gestures + private _pointers = new Map(); + + // Gesture reference captured when a pinch begins (null when not pinching) + private _pinchStart: { + distance: number; + midpoint: { x: number; y: number }; + scale: number; + translate: { x: number; y: number }; + } | null = null; + // Motion controllers (internal) private _translate: ReturnType>; private _scale: ReturnType>; @@ -141,6 +161,7 @@ export class TransformState { this.scaleExtent = options.scaleExtent; this.translateExtent = options.translateExtent; this.constrain = options.constrain; + this.pinch = options.pinch ?? true; // Inertia const inertiaOpt = options.inertia; @@ -232,9 +253,32 @@ export class TransformState { return { scale, translate }; } + /** + * Motion options which apply the change immediately (used while following a pointer/wheel gesture) + */ + private _instantMotion(motion: { type: string }) { + return motion.type === 'spring' + ? { instant: true } + : motion.type === 'tween' + ? { duration: 0 } + : undefined; + } + + /** + * In domain mode, reflect the Y coordinate since screen Y is inverted vs data Y. This ensures + * zooming targets the correct data position under the cursor/gesture. + */ + private _reflectPoint(point: { x: number; y: number }) { + if (!this.ctx || this.mode !== 'domain' || this.axis === 'x') return point; + return { + x: point.x, + y: this.ctx.padding.top + this.ctx.height - (point.y - this.ctx.padding.top), + }; + } + // Derived state get moving() { - return this.dragging || this._translating.current || this._scaling.current; + return this.dragging || this.pinching || this._translating.current || this._scaling.current; } // Public getters and setters for scale and translate @@ -328,14 +372,7 @@ export class TransformState { ) { if (!this.ctx) return; - // In domain mode, reflect Y point because screen Y is inverted vs data Y. - // This ensures zoom targets the correct data position under the cursor. - if (this.mode === 'domain' && this.axis !== 'x') { - point = { - x: point.x, - y: this.ctx.padding.top + this.ctx.height - (point.y - this.ctx.padding.top), - }; - } + point = this._reflectPoint(point); const currentScale = this._scale.current; const newScale = this._clampScale(this._scale.current * value); @@ -362,11 +399,130 @@ export class TransformState { } } + /** Continue receiving events for a pointer after it leaves the element */ + private _capturePointer(e: PointerEvent & { currentTarget: HTMLElement }) { + try { + e.currentTarget?.setPointerCapture(e.pointerId); + } catch { + // Pointer is no longer active + } + } + + /** The two oldest active pointers, if a pinch gesture is possible */ + private _pinchPoints() { + if (this._pointers.size < 2) return null; + const [a, b] = [...this._pointers.values()]; + return [a, b] as const; + } + + /** Distance between pinch pointers, restricted to the active axis in domain mode */ + private _pinchDistance(a: { x: number; y: number }, b: { x: number; y: number }) { + const dx = b.x - a.x; + const dy = b.y - a.y; + if (this.mode === 'domain') { + if (this.axis === 'x') return Math.abs(dx); + if (this.axis === 'y') return Math.abs(dy); + } + return Math.hypot(dx, dy); + } + + /** Capture the current scale/translate as the reference for the (re)starting pinch gesture */ + private _startPinch() { + const points = this._pinchPoints(); + if (!points) return; + + const distance = this._pinchDistance(points[0], points[1]); + if (distance === 0) return; + + this._pinchStart = { + distance, + midpoint: { + x: (points[0].x + points[1].x) / 2, + y: (points[0].y + points[1].y) / 2, + }, + scale: this._scale.current, + translate: this._translate.current, + }; + this.pinching = true; + this.pointerDown = true; + // Suppress click/tooltip while pinching (same as dragging) + this.dragging = true; + // Do not carry pointer movement from before the pinch into inertia + this._pointerSamples = []; + } + + private _endPinch() { + this._pinchStart = null; + this.pinching = false; + } + + private _updatePinch() { + const pinchStart = this._pinchStart; + const points = this._pinchPoints(); + if (!pinchStart || !points || !this.ctx) return; + + const distance = this._pinchDistance(points[0], points[1]); + if (distance === 0) return; + + const midpoint = { + x: (points[0].x + points[1].x) / 2, + y: (points[0].y + points[1].y) / 2, + }; + + const newScale = this._clampScale(pinchStart.scale * (distance / pinchStart.distance)); + this.setScale(newScale, this._instantMotion(this._scale)); + + const translateMotion = this._instantMotion(this._translate); + + if (this.processTranslate) { + // Translate is not in screen space (ex. globe rotation) - pan by the midpoint delta, same as dragging + this.setTranslate( + this._applyTranslate( + pinchStart.translate.x, + pinchStart.translate.y, + midpoint.x - pinchStart.midpoint.x, + midpoint.y - pinchStart.midpoint.y + ), + translateMotion + ); + } else { + // Keep the content under the initial midpoint anchored to the current midpoint, which + // handles zooming and two-finger panning together + const startMidpoint = this._reflectPoint(pinchStart.midpoint); + const currentMidpoint = this._reflectPoint(midpoint); + + const invertTransformPoint = { + x: (startMidpoint.x - this.ctx.padding.left - pinchStart.translate.x) / pinchStart.scale, + y: (startMidpoint.y - this.ctx.padding.top - pinchStart.translate.y) / pinchStart.scale, + }; + const newTranslate = { + x: currentMidpoint.x - this.ctx.padding.left - invertTransformPoint.x * newScale, + y: currentMidpoint.y - this.ctx.padding.top - invertTransformPoint.y * newScale, + }; + + // Constrain translate to active axis in domain mode + if (this.mode === 'domain') { + if (this.axis === 'x') newTranslate.y = 0; + if (this.axis === 'y') newTranslate.x = 0; + } + + this.setTranslate(newTranslate, translateMotion); + } + } + onPointerDown(e: PointerEvent & { currentTarget: HTMLElement }) { if (this.mode === 'none' || this.disablePointer) return; e.preventDefault(); + this._pointers.set(e.pointerId, localPoint(e)); + + if (this.pinch && this._pointers.size >= 2) { + // Additional pointer - transition from dragging (or a previous pinch) to a new pinch gesture + this._startPinch(); + return; + } + this.pointerDown = true; this.dragging = false; this.startPoint = localPoint(e); @@ -377,11 +533,27 @@ export class TransformState { } onPointerMove(e: PointerEvent & { currentTarget: HTMLElement }) { - if (!this.pointerDown) return; + const endPoint = this._pointers.has(e.pointerId) ? localPoint(e) : null; + if (endPoint) this._pointers.set(e.pointerId, endPoint); + + if (this.pinch && !this._pinchStart && this._pointers.size >= 2) { + // Pointers started at the same location (zero distance) - anchor the pinch once they separate + this._startPinch(); + } + + if (this._pinchStart) { + e.preventDefault(); + e.stopPropagation(); // Stop tooltip from triggering (along with `capture: true`) + // Keep receiving moves for both pointers, even if one leaves the element + this._capturePointer(e); + this._updatePinch(); + return; + } + + if (!this.pointerDown || !endPoint) return; e.preventDefault(); // Stop text selection - const endPoint = localPoint(e); const deltaX = endPoint.x - this.startPoint.x; const deltaY = endPoint.y - this.startPoint.y; @@ -392,7 +564,7 @@ export class TransformState { if (this.dragging) { e.stopPropagation(); // Stop tooltip from triggering (along with `capture: true`) - e.currentTarget?.setPointerCapture(e.pointerId); + this._capturePointer(e); // Track pointer samples for inertia velocity calculation if (this.inertia.enabled) { @@ -407,16 +579,39 @@ export class TransformState { this.setTranslate( this._applyTranslate(this.startTranslate.x, this.startTranslate.y, deltaX, deltaY), - this._translate.type === 'spring' - ? { instant: true } - : this._translate.type === 'tween' - ? { duration: 0 } - : undefined + this._instantMotion(this._translate) ); } } - onPointerUp(_e: PointerEvent & { currentTarget: HTMLElement }) { + onPointerUp(e: PointerEvent & { currentTarget: HTMLElement }) { + this._pointers.delete(e.pointerId); + + if (this._pinchStart) { + if (this._pointers.size >= 2) { + // Still enough pointers to pinch - re-anchor to the remaining pointers + this._startPinch(); + return; + } + + this._endPinch(); + + const remaining = [...this._pointers.values()][0]; + if (remaining) { + // Continue dragging with the remaining pointer without jumping + this.startPoint = remaining; + this.startTranslate = this._translate.current; + this._pointerSamples = []; + return; + } + + // Pinch ended without inertia (velocity samples are not tracked while pinching) + this.pointerDown = false; + this.dragging = false; + this.ondragend?.(); + return; + } + const wasDragging = this.dragging; this.pointerDown = false; this.dragging = false; @@ -500,6 +695,38 @@ export class TransformState { this.ondragend?.(); } + /** + * Release a cancelled pointer (ex. touch interrupted by the browser). Without this, stale + * pointers would remain active and be treated as part of a subsequent pinch gesture. + */ + onPointerCancel(e: PointerEvent & { currentTarget: HTMLElement }) { + this._pointers.delete(e.pointerId); + + if (this._pinchStart) { + if (this._pointers.size >= 2) { + this._startPinch(); + return; + } + this._endPinch(); + } + + const remaining = [...this._pointers.values()][0]; + if (remaining) { + // Continue dragging with the remaining pointer without jumping + this.startPoint = remaining; + this.startTranslate = this._translate.current; + this._pointerSamples = []; + return; + } + + if (!this.pointerDown) return; + + this.pointerDown = false; + this.dragging = false; + this._pointerSamples = []; + this.ondragend?.(); + } + onDoubleClick(e: MouseEvent & { currentTarget: HTMLElement }) { if (this.mode === 'none') return; const point = localPoint(e); @@ -531,12 +758,7 @@ export class TransformState { // Pinch to zoom is registered as a wheel event with control key const pinchToZoom = e.ctrlKey; - const instantMotionOptions = - this._scale.type === 'spring' - ? { instant: true } - : this._scale.type === 'tween' - ? { duration: 0 } - : undefined; + const instantMotionOptions = this._instantMotion(this._scale); if (this.scrollMode === 'scale' || pinchToZoom) { // https://github.com/d3/d3-zoom#zoom_wheelDelta