Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(react-chart): add series hit testing (#1496)
- Loading branch information
1 parent
2682eaa
commit d789d32
Showing
22 changed files
with
720 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' }); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.