Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/drag-click-swallowed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(TransformContext): Don't click what a pan, rotate, or pinch is released over
5 changes: 5 additions & 0 deletions .changeset/isometric-drag-echo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Chart): Don't ease an `isometric` view's angles while dragging
5 changes: 5 additions & 0 deletions .changeset/isometric-motion-reactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Chart): Ease an `isometric` view added after the chart was created, starting it at its own angles
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,78 @@ describe('Chart isometric `motion`', () => {
await vi.waitFor(() => expect(ctx.transform.rotation).toEqual({ x: 0, y: 0 }));
expect(ctx.transform.translate).toEqual({ x: 30, y: 10 });
});

it.each([
['as it is', (angle: number) => angle],
['rounded', Math.round],
])(
"follows a drag's rotation handed back %s as the view's angles, wrapped to ±180°",
async (_, echo) => {
const { ctx, turn } = await renderEased({ mode: 'canvas', drag: 'rotate' });
const el = ctx.containerRef!.querySelector('.lc-transform-context')!;
const box = el.getBoundingClientRect();
const at = (x: number) => ({
bubbles: true,
pointerId: 1,
clientX: box.x + x,
clientY: box.y,
});
const wrap = (angle: number) => ((((angle + 180) % 360) + 360) % 360) - 180;
/** The same turn, either way round */
const same = (a: number, b: number) => expect(Math.abs(wrap(a - b))).toBeLessThanOrEqual(0.5);

// Dragging right turns from -45° towards -180°, and on past it to 170°
el.dispatchEvent(new PointerEvent('pointerdown', at(0)));
for (const x of [100, 200, 270, 290]) {
el.dispatchEvent(new PointerEvent('pointermove', at(x)));
const turned = -45 - x / 2;
same(ctx.transform.rotation!.x, turned);

// As `onTransform` would, for a slider that runs -180° to 180°
const rotate = echo(wrap(ctx.transform.rotation!.x));
const tilt = echo(ctx.transform.rotation!.y);
await turn({ rotate, tilt });
// Not eased back behind the pointer
expect(ctx.isometricAngles).toEqual({ rotate, tilt });
same(ctx.transform.rotation!.x, turned);
}
el.dispatchEvent(new PointerEvent('pointerup', at(290)));
}
);

it('eases a transformed view given after the chart was made, from its own angles', async () => {
let ctx: ChartState<any, any, any> = null!;
const motion = { type: 'tween' as const, duration: 300 };
const chartProps = (view?: ReturnType<typeof isometric>) => ({
data,
x: 'x',
y: 'y',
xDomain: [0, 10],
yDomain: [0, 10],
width: 400,
height: 300,
view,
transform: { mode: 'canvas' },
});
const screen = render(TooltipTestHarness, {
chartProps: chartProps(),
oncontext: (c: any) => (ctx = c),
});
await vi.waitFor(() => expect(ctx?.transformState).toBeTruthy());
expect(ctx.transform.rotation).toBeNull();

// Straight to the view's angles, not eased from the defaults
await screen.rerender({ chartProps: chartProps(isometric({ rotate: 0, tilt: 0, motion })) });
expect(ctx.transform.rotation).toEqual({ x: 0, y: 0 });

await screen.rerender({ chartProps: chartProps(isometric({ rotate: 0, tilt: 60, motion })) });
await vi.waitFor(() => {
const { y } = ctx.transform.rotation!;
expect(y).toBeGreaterThan(0);
expect(y).toBeLessThan(60);
});
await vi.waitFor(() => expect(ctx.transform.rotation).toEqual({ x: 0, y: 60 }));
});
});

describe('Chart isometric brush', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,7 @@

function onClick(e: MouseEvent & { currentTarget: HTMLElement }) {
onclickcapture?.(e);
if (transformState.dragging) {
// Do not propagate click event to children if drag/moved. Registered in capture phase (top-down)
e.stopPropagation();
}
transformState.onClickCapture(e);
}

function onDoubleClick(e: MouseEvent & { currentTarget: HTMLElement }) {
Expand Down
158 changes: 158 additions & 0 deletions packages/layerchart/src/lib/components/TransformContext.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';

import TransformTestHarness from '$lib/tests/TransformTestHarness.svelte';
import { isometric } from '$lib/views/isometric.js';
import { geoMercator, geoOrthographic } from 'd3-geo';

describe('TransformContext', () => {
Expand Down Expand Up @@ -467,4 +468,161 @@ describe('TransformContext', () => {
expect(transform.scale).toBe(1);
});
});

describe('click after a drag', () => {
/** Render the harness with a clickable child inside the transform context */
async function setup(chartProps: Record<string, any>) {
let chartContext: any;

render(TransformTestHarness, {
chartProps: { height: 300, ...chartProps },
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<HTMLElement>('.lc-transform-context');
expect(el).not.toBeNull();
return el!;
});

// Stands in for a mark (ex. a treemap node that zooms when clicked)
const mark = document.createElement('div');
element.append(mark);
const onclick = vi.fn();
mark.addEventListener('click', onclick);

const rect = element.getBoundingClientRect();
function dispatch(type: string, x: number, y: number, pointerId = 1) {
mark.dispatchEvent(
new PointerEvent(type, {
pointerId,
pointerType: 'mouse',
clientX: rect.left + x,
clientY: rect.top + y,
bubbles: true,
cancelable: true,
})
);
}

/** The `click` the browser fires after `pointerup` (`detail: 0` is a keyboard click) */
function click(x: number, y: number, detail = 1) {
mark.dispatchEvent(
new MouseEvent('click', {
clientX: rect.left + x,
clientY: rect.top + y,
detail,
bubbles: true,
cancelable: true,
})
);
}

/** Press, move, and release, followed by the browser's `click` */
function drag(from: [number, number], to: [number, number]) {
dispatch('pointerdown', ...from);
dispatch('pointermove', ...to);
dispatch('pointerup', ...to);
click(...to);
}

return {
get transform() {
return chartContext.transform;
},
dispatch,
click,
drag,
onclick,
};
}

it('should not click what a pan is released over', async () => {
const { transform, drag, onclick } = await setup({ transform: { mode: 'canvas' } });

drag([100, 100], [150, 120]);

expect(transform.translate).toEqual({ x: 50, y: 20 });
expect(onclick).not.toHaveBeenCalled();
});

it('should not click what a rotate is released over', async () => {
const { transform, drag, onclick } = await setup({
view: isometric,
transform: { mode: 'canvas', drag: 'rotate' },
});
await vi.waitFor(() => expect(transform.rotation).not.toBeNull());
const before = { ...transform.rotation };

drag([100, 100], [150, 100]);

expect(transform.rotation.x).not.toBe(before.x);
expect(onclick).not.toHaveBeenCalled();
});

it('should click when the pointer stays within `clickDistance`', async () => {
const { drag, onclick } = await setup({ transform: { mode: 'canvas' } });

drag([100, 100], [101, 101]);

expect(onclick).toHaveBeenCalledOnce();
});

it('should click on the next press after a drag that ended without a click', async () => {
const { dispatch, click, onclick } = await setup({ transform: { mode: 'canvas' } });

// Released without the browser firing `click` (ex. over another element)
dispatch('pointerdown', 100, 100);
dispatch('pointermove', 150, 100);
dispatch('pointerup', 150, 100);

dispatch('pointerdown', 150, 100);
dispatch('pointerup', 150, 100);
click(150, 100);

expect(onclick).toHaveBeenCalledOnce();
});

it('should only swallow the one click', async () => {
const { drag, click, onclick } = await setup({ transform: { mode: 'canvas' } });

drag([100, 100], [150, 100]);
expect(onclick).not.toHaveBeenCalled();

click(150, 100);
expect(onclick).toHaveBeenCalledOnce();
});

it('should not swallow a keyboard click', async () => {
const { dispatch, click, onclick } = await setup({ transform: { mode: 'canvas' } });

dispatch('pointerdown', 100, 100);
dispatch('pointermove', 150, 100);
dispatch('pointerup', 150, 100);
click(150, 100, 0);

expect(onclick).toHaveBeenCalledOnce();
});

it('should not click what a pinch is released over', async () => {
const { transform, dispatch, click, onclick } = await setup({
transform: { mode: 'canvas' },
});

dispatch('pointerdown', 100, 100, 1);
dispatch('pointerdown', 200, 100, 2);
dispatch('pointermove', 300, 100, 2);
dispatch('pointerup', 300, 100, 2);
dispatch('pointerup', 100, 100, 1);
click(100, 100);

expect(transform.scale).toBe(2);
expect(onclick).not.toHaveBeenCalled();
});
});
});
45 changes: 34 additions & 11 deletions packages/layerchart/src/lib/states/chart.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { TooltipState } from './tooltip.svelte.js';
import type { BrushDomainType, BrushState } from './brush.svelte.js';
import { SeriesState, type SeriesLayout, type StackLayout } from './series.svelte.js';
import type { SeriesData } from '$lib/components/charts/types.js';
import { createControlledMotion, createMotion, parseMotionProp } from '$lib/utils/motion.svelte.js';
import { createControlledMotion, parseMotionProp } from '$lib/utils/motion.svelte.js';

const defaultPadding = { top: 0, right: 0, bottom: 0, left: 0 };

Expand Down Expand Up @@ -79,6 +79,9 @@ export interface MarkInfo {

export type NodeKind = 'group' | 'mark' | 'composite-mark';

/** An `isometric` view's turn and tip */
type IsometricAngles = { rotate: number; tilt: number };

export interface ComponentNode {
id: symbol;
kind: NodeKind;
Expand Down Expand Up @@ -417,12 +420,28 @@ export class ChartState<
// Read once — identity must stay stable for the life of the chart
this.id = props.id ?? Symbol('Chart');

// The view's `motion` is read once, like other `motion` props
this.#angles = createMotion(
this.#propAngles,
() => this.#propAngles,
this.#viewOptions?.motion
);
let shown = false;
$effect.pre(() => {
const options = this.#viewOptions;
const angles = this.#propAngles;
untrack(() => {
// A view that appears starts at its own angles, having none to ease from, and a drag owns
// the view while it lasts (ex. its rotation handed back from `onTransform`)
const instant = !shown || !!this.transformState?.dragging;
// Without a view the angles hold, for one that comes back
shown = !!options;
if (!options) return;
if (!this.#angles && options.motion) {
this.#angles = createControlledMotion(angles, options.motion);
}
const motion = this.#angles;
if (!motion) return;
motion.set(
angles,
instant ? (motion.type === 'tween' ? { duration: 0 } : { instant: true }) : undefined
);
});
});

// Create GeoState instance — pass a dimensions getter so projection
// is available during SSR (where $effect doesn't run)
Expand Down Expand Up @@ -1149,16 +1168,20 @@ export class ChartState<
);

/** The `isometric` options' `rotate` / `tilt`, with their defaults */
#propAngles = $derived.by(() => {
#propAngles = $derived.by((): IsometricAngles => {
const options = this.#viewOptions ?? {};
return { rotate: options.rotate ?? -45, tilt: options.tilt ?? ISOMETRIC_TILT };
});

#angles: { readonly current: { rotate: number; tilt: number } };
/**
* The angles eased by the view's `motion`, read once from the first view that has one (a chart
* may start without a view). `null` until then, following the angles as given.
*/
#angles = $state.raw<ReturnType<typeof createControlledMotion<IsometricAngles>> | null>(null);

/** The `isometric` angles, eased by their `motion`; a transform rotates the view from these */
get isometricAngles() {
return this.#angles.current;
get isometricAngles(): IsometricAngles {
return this.#angles?.current ?? this.#propAngles;
}

/**
Expand Down
Loading
Loading