Skip to content

Commit

Permalink
chore(react-chart): add series hit testing (#1496)
Browse files Browse the repository at this point in the history
  • Loading branch information
DmitryBogomolov committed Oct 20, 2018
1 parent 2682eaa commit d789d32
Show file tree
Hide file tree
Showing 22 changed files with 720 additions and 54 deletions.
2 changes: 2 additions & 0 deletions packages/dx-chart-core/src/index.js
Expand Up @@ -3,6 +3,8 @@ export * from './plugins/layout-manager/computeds';
export * from './plugins/axis/computeds';
export * from './plugins/series/computeds';
export * from './plugins/stack/computeds';
export * from './utils/series';
export * from './utils/scale';
export * from './utils/legend';
export * from './utils/tracker';
export * from './constants';
10 changes: 9 additions & 1 deletion packages/dx-chart-core/src/plugins/series/computeds.js
Expand Up @@ -68,17 +68,24 @@ export const getPiePointTransformer = ({
const y = Math.max(...valueScale.range()) / 2;
const radius = Math.min(x, y);
const pieData = pie().sort(null).value(d => d[valueField])(data);
const gen = arc().innerRadius(innerRadius * radius).outerRadius(outerRadius * radius);
const inner = innerRadius * radius;
const outer = outerRadius * radius;
const gen = arc().innerRadius(inner).outerRadius(outer);
const colorScale = scaleOrdinal().range(palette);
return ({ argument, value, index }) => {
const { startAngle, endAngle } = pieData[index];
return {
// TODO: It should be calculated in *pointComponent*.
d: gen.startAngle(startAngle).endAngle(endAngle)(),
value,
color: colorScale(index),
id: argument,
x,
y,
innerRadius: inner,
outerRadius: outer,
startAngle,
endAngle,
};
};
};
Expand Down Expand Up @@ -143,6 +150,7 @@ export const dBar = ({
export const pointAttributes = ({ size = DEFAULT_POINT_SIZE }) => {
const dPoint = symbol().size([size ** 2]).type(symbolCircle)();
return item => ({
// TODO: It should be calculated in *pointComponent*.
d: dPoint,
x: item.x,
y: item.y,
Expand Down
8 changes: 8 additions & 0 deletions packages/dx-chart-core/src/plugins/series/computeds.test.js
Expand Up @@ -305,6 +305,10 @@ describe('getPiePointTransformer', () => {
value: 'val-1',
color: 'c1',
d: 'test-arc-1',
innerRadius: 4,
outerRadius: 6,
startAngle: 3,
endAngle: 4,
});
expect(
transform({ argument: 'arg-2', value: 'val-2', index: 3 }),
Expand All @@ -315,6 +319,10 @@ describe('getPiePointTransformer', () => {
value: 'val-2',
color: 'c2',
d: 'test-arc-2',
innerRadius: 4,
outerRadius: 6,
startAngle: 7,
endAngle: 8,
});

expect(mockPie.sort).toBeCalledWith(null);
Expand Down
82 changes: 82 additions & 0 deletions packages/dx-chart-core/src/utils/series.js
@@ -0,0 +1,82 @@
import { area } from 'd3-shape';
import { dArea, dLine, dSpline } from '../plugins/series/computeds';

// This function is called from event handlers (when DOM is available) -
// *window.document* can be accessed safely.
const createContext = () => document.createElement('canvas').getContext('2d'); // eslint-disable-line no-undef

// For a start using browser canvas will suffice.
// However a better and more clean solution should be found.
// Can't d3 perform hit testing?
const createCanvasAbusingHitTesterCreator = makePath => (coordinates) => {
const ctx = createContext();
const path = makePath();
path.context(ctx);
path(coordinates);
return ([x, y]) => (ctx.isPointInPath(x, y) ? {} : null);
};

export const createAreaHitTester = createCanvasAbusingHitTesterCreator(() => {
const path = area();
path.x(dArea.x());
path.y1(dArea.y1());
path.y0(dArea.y0());
return path;
});

export const createLineHitTester = createCanvasAbusingHitTesterCreator(() => {
const path = area();
const getY = dLine.y();
path.x(dLine.x());
path.y1(point => getY(point) - 10);
path.y0(point => getY(point) + 10);
return path;
});

export const createSplineHitTester = createCanvasAbusingHitTesterCreator(() => {
const path = area();
const getY = dSpline.y();
path.x(dSpline.x());
path.y1(point => getY(point) - 10);
path.y0(point => getY(point) + 10);
path.curve(dSpline.curve());
return path;
});

const isPointInRect = (x, y, x1, x2, y1, y2) => x1 <= x && x <= x2 && y1 <= y && y <= y2;

export const createBarHitTester = coordinates => ([px, py]) => {
const point = coordinates.find(({
x, width, y, y1,
}) => isPointInRect(px, py, x, x + width, Math.min(y, y1), Math.max(y, y1)));
return point ? { point: point.id } : null;
};

// TODO: Use actual point size here!
export const createScatterHitTester = coordinates => ([px, py]) => {
const point = coordinates.find(({
x, y,
}) => isPointInRect(px, py, x - 10, x + 10, y - 10, y + 10));
return point ? { point: point.id } : null;
};

const mapAngleTod3 = (angle) => {
const ret = angle + Math.PI / 2;
return ret >= 0 ? ret : ret + Math.PI * 2;
};

export const createPieHitTester = coordinates => ([px, py]) => {
const point = coordinates.find(({
x, y, innerRadius, outerRadius, startAngle, endAngle,
}) => {
const dx = px - x;
const dy = py - y;
const r = Math.sqrt(dx * dx + dy * dy);
if (r < innerRadius || r > outerRadius) {
return null;
}
const angle = mapAngleTod3(Math.atan2(dy, dx));
return startAngle <= angle && angle <= endAngle;
});
return point ? { point: point.id } : null;
};
232 changes: 232 additions & 0 deletions packages/dx-chart-core/src/utils/series.test.js
@@ -0,0 +1,232 @@
import { area } from 'd3-shape';
import { dArea, dLine, dSpline } from '../plugins/series/computeds';
import {
createAreaHitTester, createLineHitTester, createSplineHitTester,
createBarHitTester, createScatterHitTester, createPieHitTester,
} from './series';

jest.mock('d3-shape', () => ({
area: jest.fn(),
}));

jest.mock('../plugins/series/computeds', () => ({
dArea: { x: jest.fn(), y0: jest.fn(), y1: jest.fn() },
dLine: { x: jest.fn(), y: jest.fn() },
dSpline: { x: jest.fn(), y: jest.fn(), curve: jest.fn() },
}));

const getContext = jest.fn();
// eslint-disable-next-line no-undef
document.createElement = () => ({ getContext });

describe('Series', () => {
// Mocks are intentionally reset rather then cleared.
afterEach(jest.resetAllMocks);

describe('#createAreaHitTester', () => {
const mockPath = jest.fn();
mockPath.x = jest.fn();
mockPath.y0 = jest.fn();
mockPath.y1 = jest.fn();
mockPath.context = jest.fn();

beforeEach(() => {
dArea.x.mockReturnValue('#x');
dArea.y0.mockReturnValue('#y0');
dArea.y1.mockReturnValue('#y1');

area.mockReturnValue(mockPath);
});

it('should setup context', () => {
getContext.mockReturnValue('test-context');

createAreaHitTester('test-coordinates');

expect(mockPath.x).toBeCalledWith('#x');
expect(mockPath.y0).toBeCalledWith('#y0');
expect(mockPath.y1).toBeCalledWith('#y1');
expect(mockPath.context).toBeCalledWith('test-context');
expect(mockPath).toBeCalledWith('test-coordinates');
});

it('should call context method', () => {
const isPointInPath = jest.fn();
isPointInPath.mockReturnValueOnce(false);
isPointInPath.mockReturnValueOnce(true);
isPointInPath.mockReturnValueOnce(true);
isPointInPath.mockReturnValueOnce(false);
getContext.mockReturnValue({ isPointInPath });

const hitTest = createAreaHitTester('test-coordinates');

expect(hitTest([1, 2])).toEqual(null);
expect(hitTest([3, 4])).toEqual({});
expect(hitTest([5, 6])).toEqual({});
expect(hitTest([7, 8])).toEqual(null);

expect(isPointInPath.mock.calls).toEqual([
[1, 2],
[3, 4],
[5, 6],
[7, 8],
]);
});
});

describe('#createLineHitTester', () => {
const mockPath = jest.fn();
mockPath.x = jest.fn();
mockPath.y0 = jest.fn();
mockPath.y1 = jest.fn();
mockPath.context = jest.fn();

beforeEach(() => {
dLine.x.mockReturnValue('#x');
dLine.y.mockReturnValue('#y');

area.mockReturnValue(mockPath);
});

it('should setup context', () => {
getContext.mockReturnValue('test-context');

createLineHitTester('test-coordinates');

expect(mockPath.x).toBeCalledWith('#x');
expect(mockPath.y0).toBeCalledWith(expect.any(Function));
expect(mockPath.y1).toBeCalledWith(expect.any(Function));
expect(mockPath.context).toBeCalledWith('test-context');
expect(mockPath).toBeCalledWith('test-coordinates');
});

it('should call context method', () => {
const isPointInPath = jest.fn();
isPointInPath.mockReturnValueOnce(false);
isPointInPath.mockReturnValueOnce(true);
isPointInPath.mockReturnValueOnce(true);
isPointInPath.mockReturnValueOnce(false);
getContext.mockReturnValue({ isPointInPath });

const hitTest = createLineHitTester('test-coordinates');

expect(hitTest([1, 2])).toEqual(null);
expect(hitTest([3, 4])).toEqual({});
expect(hitTest([5, 6])).toEqual({});
expect(hitTest([7, 8])).toEqual(null);

expect(isPointInPath.mock.calls).toEqual([
[1, 2],
[3, 4],
[5, 6],
[7, 8],
]);
});
});

describe('#createSplineHitTester', () => {
const mockPath = jest.fn();
mockPath.x = jest.fn();
mockPath.y0 = jest.fn();
mockPath.y1 = jest.fn();
mockPath.curve = jest.fn();
mockPath.context = jest.fn();

beforeEach(() => {
dSpline.x.mockReturnValue('#x');
dSpline.y.mockReturnValue('#y');
dSpline.curve.mockReturnValue('#curve');

area.mockReturnValue(mockPath);
});

it('should setup context', () => {
getContext.mockReturnValue('test-context');

createSplineHitTester('test-coordinates');

expect(mockPath.x).toBeCalledWith('#x');
expect(mockPath.y0).toBeCalledWith(expect.any(Function));
expect(mockPath.y1).toBeCalledWith(expect.any(Function));
expect(mockPath.curve).toBeCalledWith('#curve');
expect(mockPath.context).toBeCalledWith('test-context');
expect(mockPath).toBeCalledWith('test-coordinates');
});

it('should call context method', () => {
const isPointInPath = jest.fn();
isPointInPath.mockReturnValueOnce(false);
isPointInPath.mockReturnValueOnce(true);
isPointInPath.mockReturnValueOnce(true);
isPointInPath.mockReturnValueOnce(false);
getContext.mockReturnValue({ isPointInPath });

const hitTest = createSplineHitTester('test-coordinates');

expect(hitTest([1, 2])).toEqual(null);
expect(hitTest([3, 4])).toEqual({});
expect(hitTest([5, 6])).toEqual({});
expect(hitTest([7, 8])).toEqual(null);

expect(isPointInPath.mock.calls).toEqual([
[1, 2],
[3, 4],
[5, 6],
[7, 8],
]);
});
});

describe('#createBarHitTester', () => {
it('should test bars', () => {
const hitTest = createBarHitTester([
{
x: 10, width: 4, y: 2, y1: 4, id: 'p1',
},
{
x: 20, width: 8, y: 3, y1: 5, id: 'p2',
},
{
x: 30, width: 5, y: 1, y1: 5, id: 'p3',
},
]);

expect(hitTest([15, 1])).toEqual(null);
expect(hitTest([12, 4])).toEqual({ point: 'p1' });
expect(hitTest([25, 3])).toEqual({ point: 'p2' });
expect(hitTest([31, 2])).toEqual({ point: 'p3' });
});
});

describe('#createScatterHitTester', () => {
it('should test points', () => {
const hitTest = createScatterHitTester([
{ x: 10, y: 4, id: 'p1' },
{ x: 30, y: 5, id: 'p2' },
{ x: 50, y: 8, id: 'p3' },
]);

expect(hitTest([15, -7])).toEqual(null);
expect(hitTest([14, 10])).toEqual({ point: 'p1' });
expect(hitTest([32, 4])).toEqual({ point: 'p2' });
expect(hitTest([47, 18])).toEqual({ point: 'p3' });
});
});

describe('#createPieHitTester', () => {
it('should test pies', () => {
const hitTest = createPieHitTester([
{
x: 60, y: 50, innerRadius: 1, outerRadius: 10, startAngle: 0, endAngle: Math.PI / 4, id: 'p1',
},
{
x: 60, y: 50, innerRadius: 1, outerRadius: 10, startAngle: Math.PI / 2, endAngle: Math.PI, id: 'p2',
},
]);

expect(hitTest([60, 61])).toEqual(null);
expect(hitTest([64, 45])).toEqual({ point: 'p1' });
expect(hitTest([68, 52])).toEqual({ point: 'p2' });
});
});
});

0 comments on commit d789d32

Please sign in to comment.