Skip to content

Commit

Permalink
Optimized nearest element search (#334)
Browse files Browse the repository at this point in the history
* Search nearest bar.

* Get closest point for lines.

* Use D3 quad tree for Scatterplot nearest point search.

* Ability to highlight points in equal location.

* Fixed highlighting proper bar on equal X (or Y for horizontal).
  • Loading branch information
alexanderby committed Jan 31, 2017
1 parent 6e369ac commit 2456aef
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 83 deletions.
1 change: 1 addition & 0 deletions src/elements/element.area.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const Area = {
highlight: BasePath.highlight,
highlightDataPoints: BasePath.highlightDataPoints,
addInteraction: BasePath.addInteraction,
_getBoundsInfo: BasePath._getBoundsInfo,

init(xConfig) {

Expand Down
140 changes: 109 additions & 31 deletions src/elements/element.interval.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ const Interval = {
.append('g')
.call(updateBarContainer);

// TODO: Render bars into single container, exclude removed elements from calculation.
this._boundsInfo = this._getBoundsInfo(options.container.selectAll('.bar')[0]);

node.subscribe(new LayerLabels(screenModel.model, screenModel.model.flip, config.guide.label, options)
.draw(fibers));

Expand Down Expand Up @@ -287,53 +290,128 @@ const Interval = {
});
},

getClosestElement(cursorX, cursorY) {
const container = this.node().config.options.container;
_getBoundsInfo(bars) {
if (bars.length === 0) {
return null;
}

const screenModel = this.node().screenModel;
const {flip} = this.node().config;
const {maxHighlightDistance} = this.node().config.guide;

const bars = container.selectAll('.bar');
var minX = Number.MAX_VALUE;
var maxX = Number.MIN_VALUE;
var minY = Number.MAX_VALUE;
var maxY = Number.MIN_VALUE;
const items = bars[0]
const items = bars
.map((node) => {
const data = d3.select(node).data()[0];
const translate = utilsDraw.getDeepTransformTranslate(node);
const x = screenModel.x(data);
const x0 = screenModel.x0(data);
const y = screenModel.y(data);
const y0 = screenModel.y0(data);
const w = Math.abs(x - x0);
const h = Math.abs(y - y0);
const cx = ((x + x0) / 2 + translate.x);
const cy = ((y + y0) / 2 + translate.y);
const distance = Math.abs(flip ? (cy - cursorY) : (cx - cursorX));
const secondaryDistance = Math.abs(flip ? (cx - cursorX) : (cy - cursorY));
minX = Math.min(cx - w / 2, minX);
maxX = Math.max(cx + w / 2, maxX);
minY = Math.min(cy - h / 2, minY);
maxY = Math.max(cy + h / 2, maxY);
return {node, data, distance, secondaryDistance, x: cx, y: cy};
const cx = ((x + x0) / 2);
const cy = ((y + y0) / 2);
const invert = (y > y0);

const box = {
top: (cy - h / 2),
right: (cx + w / 2),
bottom: (cy + h / 2),
left: (cx - w / 2)
};

return {node, data, cx, cy, box, invert};
})
.filter((d) => !isNaN(d.x) && !isNaN(d.y))
.sort((a, b) => (a.distance === b.distance ?
(a.secondaryDistance - b.secondaryDistance) :
(a.distance - b.distance)
));

if ((items.length === 0) ||
(cursorX < minX - maxHighlightDistance) ||
(cursorX > maxX + maxHighlightDistance) ||
(cursorY < minY - maxHighlightDistance) ||
(cursorY > maxY + maxHighlightDistance)
// TODO: Removed elements should not be passed to this function.
.filter((item) => !isNaN(item.cx) && !isNaN(item.cy));

const bounds = items.reduce(
(bounds, {box}) => {
bounds.left = Math.min(box.left, bounds.left);
bounds.right = Math.max(box.right, bounds.right);
bounds.top = Math.min(box.top, bounds.top);
bounds.bottom = Math.max(box.bottom, bounds.bottom);
return bounds;
}, {
left: Number.MAX_VALUE,
right: Number.MIN_VALUE,
top: Number.MAX_VALUE,
bottom: Number.MIN_VALUE
});

const ticks = utils.unique(items.map(flip ?
((item) => item.cy) :
((item) => item.cx))).sort((a, b) => a - b);
const groups = ticks.reduce(((obj, value) => (obj[value] = [], obj)), {});
items.forEach((item) => {
const tick = ticks.find(flip ? ((value) => item.cy === value) : ((value) => item.cx === value));
groups[tick].push(item);
});
const split = (values) => {
if (values.length === 1) {
return groups[values];
}
const midIndex = Math.ceil(values.length / 2);
const middle = (values[midIndex - 1] + values[midIndex]) / 2;
return {
middle,
lower: split(values.slice(0, midIndex)),
greater: split(values.slice(midIndex))
};
};
const tree = split(ticks);

return {bounds, tree};
},

getClosestElement(_cursorX, _cursorY) {
if (!this._boundsInfo) {
return null;
}
const {bounds, tree} = this._boundsInfo;
const container = this.node().config.options.container;
const {flip} = this.node().config;
const translate = utilsDraw.getDeepTransformTranslate(container.node());
const cursorX = (_cursorX - translate.x);
const cursorY = (_cursorY - translate.y);
const {maxHighlightDistance} = this.node().config.guide;
if ((cursorX < bounds.left - maxHighlightDistance) ||
(cursorX > bounds.right + maxHighlightDistance) ||
(cursorY < bounds.top - maxHighlightDistance) ||
(cursorY > bounds.bottom + maxHighlightDistance)
) {
return null;
}

return items[0];
const measureCursor = (flip ? cursorY : cursorX);
const valueCursor = (flip ? cursorX : cursorY);
const isBetween = ((value, start, end) => value >= start && value <= end);
const closestElements = (function getClosestElements(el) {
if (Array.isArray(el)) {
return el;
}
return getClosestElements(measureCursor > el.middle ? el.greater : el.lower);
})(tree)
.map((el) => {
const elStart = (flip ? el.box.left : el.box.top);
const elEnd = (flip ? el.box.right : el.box.bottom);
const cursorInside = isBetween(valueCursor, elStart, elEnd);
const distToValue = Math.abs(valueCursor - ((el.invert !== flip) ? elEnd : elStart));
return Object.assign(el, {distToValue, cursorInside});
})
.sort(((a, b) => {
if (a.cursorInside !== b.cursorInside) {
return (b.cursorInside - a.cursorInside);
}
return (Math.abs(a.distToValue) - Math.abs(b.distToValue));
}))
.map((el) => {
const x = (el.cx + translate.x);
const y = (el.cy + translate.y);
const distance = Math.abs(flip ? (cursorY - y) : (cursorX - x));
const secondaryDistance = Math.abs(flip ? (cursorX - x) : (cursorY - y));
return {node: el.node, data: el.data, distance, secondaryDistance, x, y};
});

return (closestElements[0] || null);
},

highlight(filter) {
Expand Down
1 change: 1 addition & 0 deletions src/elements/element.line.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const Line = {
highlight: BasePath.highlight,
highlightDataPoints: BasePath.highlightDataPoints,
addInteraction: BasePath.addInteraction,
_getBoundsInfo: BasePath._getBoundsInfo,

init(xConfig) {

Expand Down
115 changes: 86 additions & 29 deletions src/elements/element.path.base.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,53 +301,110 @@ const BasePath = {

frameBinding.order();

// TODO: Exclude removed elements from calculation.
this._boundsInfo = this._getBoundsInfo(options.container.selectAll('.i-data-anchor')[0]);

node.subscribe(new LayerLabels(
screenModel.model,
config.flip,
config.guide.label,
options).draw(pureFibers));
},

getClosestElement(cursorX, cursorY) {
const container = this.node().config.options.container;
_getBoundsInfo(dots) {
if (dots.length === 0) {
return null;
}

const screenModel = this.node().screenModel;
const {flip} = this.node().config;
const {maxHighlightDistance} = this.node().config.guide;

const dots = container.selectAll('.i-data-anchor');
var minX = Number.MAX_VALUE;
var maxX = Number.MIN_VALUE;
var minY = Number.MAX_VALUE;
var maxY = Number.MIN_VALUE;
const items = dots[0]
const items = dots
.map((node) => {
const data = d3.select(node).data()[0];
const translate = utilsDraw.getDeepTransformTranslate(node);
const x = (screenModel.x(data) + translate.x);
const y = (screenModel.y(data) + translate.y);
var distance = Math.abs(flip ? (y - cursorY) : (x - cursorX));
var secondaryDistance = Math.abs(flip ? (x - cursorX) : (y - cursorY));
minX = Math.min(x, minX);
maxX = Math.max(x, maxX);
minY = Math.min(y, minY);
maxY = Math.max(y, maxY);
return {node, data, distance, secondaryDistance, x, y};
const x = screenModel.x(data);
const y = screenModel.y(data);

return {node, data, x, y};
})
.filter((d) => d && !isNaN(d.x) && !isNaN(d.y))
.sort((a, b) => (a.distance === b.distance ?
(a.secondaryDistance - b.secondaryDistance) :
(a.distance - b.distance)
));
// TODO: Removed elements should not be passed to this function.
.filter((item) => !isNaN(item.x) && !isNaN(item.y));

const bounds = items.reduce(
(bounds, {x, y}) => {
bounds.left = Math.min(x, bounds.left);
bounds.right = Math.max(x, bounds.right);
bounds.top = Math.min(y, bounds.top);
bounds.bottom = Math.max(y, bounds.bottom);
return bounds;
}, {
left: Number.MAX_VALUE,
right: Number.MIN_VALUE,
top: Number.MAX_VALUE,
bottom: Number.MIN_VALUE
});

const ticks = utils.unique(items.map(flip ?
((item) => item.y) :
((item) => item.x))).sort((a, b) => a - b);
const groups = ticks.reduce(((obj, value) => (obj[value] = [], obj)), {});
items.forEach((item) => {
const tick = ticks.find(flip ? ((value) => item.y === value) : ((value) => item.x === value));
groups[tick].push(item);
});
const split = (values) => {
if (values.length === 1) {
return groups[values];
}
const midIndex = Math.ceil(values.length / 2);
const middle = (values[midIndex - 1] + values[midIndex]) / 2;
return {
middle,
lower: split(values.slice(0, midIndex)),
greater: split(values.slice(midIndex))
};
};
const tree = split(ticks);

if ((items.length === 0) ||
(cursorX < minX - maxHighlightDistance) ||
(cursorX > maxX + maxHighlightDistance) ||
(cursorY < minY - maxHighlightDistance) ||
(cursorY > maxY + maxHighlightDistance)
return {bounds, tree};
},

getClosestElement(cursorX, cursorY) {
if (!this._boundsInfo) {
return null;
}
const {bounds, tree} = this._boundsInfo;
const container = this.node().config.options.container;
const {flip} = this.node().config;
const translate = utilsDraw.getDeepTransformTranslate(container.node());
const {maxHighlightDistance} = this.node().config.guide;
if ((cursorX < bounds.left + translate.x - maxHighlightDistance) ||
(cursorX > bounds.right + translate.x + maxHighlightDistance) ||
(cursorY < bounds.top + translate.y - maxHighlightDistance) ||
(cursorY > bounds.bottom + translate.y + maxHighlightDistance)
) {
return null;
}

const cursor = (flip ? (cursorY - translate.y) : (cursorX - translate.x));
const items = (function getClosestElements(el) {
if (Array.isArray(el)) {
return el;
}
return getClosestElements(cursor > el.middle ? el.greater : el.lower);
})(tree)
.map((el) => {
const x = (el.x + translate.x);
const y = (el.y + translate.y);
const distance = Math.abs(flip ? (cursorY - y) : (cursorX - x));
const secondaryDistance = Math.abs(flip ? (cursorX - x) : (cursorY - y));
return {node: el.node, data: el.data, distance, secondaryDistance, x, y};
})
.sort((a, b) => (a.distance === b.distance ?
(a.secondaryDistance - b.secondaryDistance) :
(a.distance - b.distance)
));

const largerDistIndex = items.findIndex((d) => (
(d.distance !== items[0].distance) ||
(d.secondaryDistance !== items[0].secondaryDistance)
Expand Down
1 change: 1 addition & 0 deletions src/elements/element.path.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const Path = {
highlight: BasePath.highlight,
highlightDataPoints: BasePath.highlightDataPoints,
addInteraction: BasePath.addInteraction,
_getBoundsInfo: BasePath._getBoundsInfo,

init(xConfig) {

Expand Down

0 comments on commit 2456aef

Please sign in to comment.