Skip to content

Commit c80eb5a

Browse files
authored
Merge pull request #4089 from plotly/phx235-dont-drop-constraint-onclick
Bug fix - do not drop previous constraint on click
2 parents e7f275f + 30876b2 commit c80eb5a

File tree

2 files changed

+210
-119
lines changed

2 files changed

+210
-119
lines changed

src/traces/parcoords/axisbrush.js

+138-119
Original file line numberDiff line numberDiff line change
@@ -206,137 +206,156 @@ function getInterval(d, y) {
206206
return out;
207207
}
208208

209+
function dragstart(lThis, d) {
210+
d3.event.sourceEvent.stopPropagation();
211+
var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding;
212+
var unitLocation = d.unitToPaddedPx.invert(y);
213+
var b = d.brush;
214+
var interval = getInterval(d, y);
215+
var unitRange = interval.interval;
216+
var s = b.svgBrush;
217+
s.wasDragged = false; // we start assuming there won't be a drag - useful for reset
218+
s.grabbingBar = interval.region === 'ns';
219+
if(s.grabbingBar) {
220+
var pixelRange = unitRange.map(d.unitToPaddedPx);
221+
s.grabPoint = y - pixelRange[0] - c.verticalPadding;
222+
s.barLength = pixelRange[1] - pixelRange[0];
223+
}
224+
s.clickableOrdinalRange = interval.clickableOrdinalRange;
225+
s.stayingIntervals = (d.multiselect && b.filterSpecified) ? b.filter.getConsolidated() : [];
226+
if(unitRange) {
227+
s.stayingIntervals = s.stayingIntervals.filter(function(int2) {
228+
return int2[0] !== unitRange[0] && int2[1] !== unitRange[1];
229+
});
230+
}
231+
s.startExtent = interval.region ? unitRange[interval.region === 's' ? 1 : 0] : unitLocation;
232+
d.parent.inBrushDrag = true;
233+
s.brushStartCallback();
234+
}
235+
236+
function drag(lThis, d) {
237+
d3.event.sourceEvent.stopPropagation();
238+
var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding;
239+
var s = d.brush.svgBrush;
240+
s.wasDragged = true;
241+
s._dragging = true;
242+
243+
if(s.grabbingBar) { // moving the bar
244+
s.newExtent = [y - s.grabPoint, y + s.barLength - s.grabPoint].map(d.unitToPaddedPx.invert);
245+
} else { // south/north drag or new bar creation
246+
s.newExtent = [s.startExtent, d.unitToPaddedPx.invert(y)].sort(sortAsc);
247+
}
248+
249+
d.brush.filterSpecified = true;
250+
s.extent = s.stayingIntervals.concat([s.newExtent]);
251+
s.brushCallback(d);
252+
renderHighlight(lThis.parentNode);
253+
}
254+
255+
function dragend(lThis, d) {
256+
var brush = d.brush;
257+
var filter = brush.filter;
258+
var s = brush.svgBrush;
259+
260+
if(!s._dragging) { // i.e. click
261+
// mock zero drag
262+
mousemove(lThis, d);
263+
drag(lThis, d);
264+
// remember it is a click not a drag
265+
d.brush.svgBrush.wasDragged = false;
266+
}
267+
s._dragging = false;
268+
269+
var e = d3.event;
270+
e.sourceEvent.stopPropagation();
271+
var grabbingBar = s.grabbingBar;
272+
s.grabbingBar = false;
273+
s.grabLocation = undefined;
274+
d.parent.inBrushDrag = false;
275+
clearCursor(); // instead of clearing, a nicer thing would be to set it according to current location
276+
if(!s.wasDragged) { // a click+release on the same spot (ie. w/o dragging) means a bar or full reset
277+
s.wasDragged = undefined; // logic-wise unneeded, just shows `wasDragged` has no longer a meaning
278+
if(s.clickableOrdinalRange) {
279+
if(brush.filterSpecified && d.multiselect) {
280+
s.extent.push(s.clickableOrdinalRange);
281+
} else {
282+
s.extent = [s.clickableOrdinalRange];
283+
brush.filterSpecified = true;
284+
}
285+
} else if(grabbingBar) {
286+
s.extent = s.stayingIntervals;
287+
if(s.extent.length === 0) {
288+
brushClear(brush);
289+
}
290+
} else {
291+
brushClear(brush);
292+
}
293+
s.brushCallback(d);
294+
renderHighlight(lThis.parentNode);
295+
s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []);
296+
return; // no need to fuse intervals or snap to ordinals, so we can bail early
297+
}
298+
299+
var mergeIntervals = function() {
300+
// Key piece of logic: once the button is released, possibly overlapping intervals will be fused:
301+
// Here it's done immediately on click release while on ordinal snap transition it's done at the end
302+
filter.set(filter.getConsolidated());
303+
};
304+
305+
if(d.ordinal) {
306+
var a = d.unitTickvals;
307+
if(a[a.length - 1] < a[0]) a.reverse();
308+
s.newExtent = [
309+
ordinalScaleSnap(0, a, s.newExtent[0], s.stayingIntervals),
310+
ordinalScaleSnap(1, a, s.newExtent[1], s.stayingIntervals)
311+
];
312+
var hasNewExtent = s.newExtent[1] > s.newExtent[0];
313+
s.extent = s.stayingIntervals.concat(hasNewExtent ? [s.newExtent] : []);
314+
if(!s.extent.length) {
315+
brushClear(brush);
316+
}
317+
s.brushCallback(d);
318+
if(hasNewExtent) {
319+
// merging intervals post the snap tween
320+
renderHighlight(lThis.parentNode, mergeIntervals);
321+
} else {
322+
// if no new interval, don't animate, just redraw the highlight immediately
323+
mergeIntervals();
324+
renderHighlight(lThis.parentNode);
325+
}
326+
} else {
327+
mergeIntervals(); // merging intervals immediately
328+
}
329+
s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []);
330+
}
331+
332+
function mousemove(lThis, d) {
333+
var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding;
334+
var interval = getInterval(d, y);
335+
336+
var cursor = 'crosshair';
337+
if(interval.clickableOrdinalRange) cursor = 'pointer';
338+
else if(interval.region) cursor = interval.region + '-resize';
339+
d3.select(document.body)
340+
.style('cursor', cursor);
341+
}
342+
209343
function attachDragBehavior(selection) {
210344
// There's some fiddling with pointer cursor styling so that the cursor preserves its shape while dragging a brush
211345
// even if the cursor strays from the interacting bar, which is bound to happen as bars are thin and the user
212346
// will inevitably leave the hotspot strip. In this regard, it does something similar to what the D3 brush would do.
213347
selection
214348
.on('mousemove', function(d) {
215349
d3.event.preventDefault();
216-
if(!d.parent.inBrushDrag) {
217-
var y = d.height - d3.mouse(this)[1] - 2 * c.verticalPadding;
218-
var interval = getInterval(d, y);
219-
220-
var cursor = 'crosshair';
221-
if(interval.clickableOrdinalRange) cursor = 'pointer';
222-
else if(interval.region) cursor = interval.region + '-resize';
223-
d3.select(document.body)
224-
.style('cursor', cursor);
225-
}
350+
if(!d.parent.inBrushDrag) mousemove(this, d);
226351
})
227352
.on('mouseleave', function(d) {
228353
if(!d.parent.inBrushDrag) clearCursor();
229354
})
230355
.call(d3.behavior.drag()
231-
.on('dragstart', function(d) {
232-
d3.event.sourceEvent.stopPropagation();
233-
var y = d.height - d3.mouse(this)[1] - 2 * c.verticalPadding;
234-
var unitLocation = d.unitToPaddedPx.invert(y);
235-
var b = d.brush;
236-
var interval = getInterval(d, y);
237-
var unitRange = interval.interval;
238-
var s = b.svgBrush;
239-
s.wasDragged = false; // we start assuming there won't be a drag - useful for reset
240-
s.grabbingBar = interval.region === 'ns';
241-
if(s.grabbingBar) {
242-
var pixelRange = unitRange.map(d.unitToPaddedPx);
243-
s.grabPoint = y - pixelRange[0] - c.verticalPadding;
244-
s.barLength = pixelRange[1] - pixelRange[0];
245-
}
246-
s.clickableOrdinalRange = interval.clickableOrdinalRange;
247-
s.stayingIntervals = (d.multiselect && b.filterSpecified) ? b.filter.getConsolidated() : [];
248-
if(unitRange) {
249-
s.stayingIntervals = s.stayingIntervals.filter(function(int2) {
250-
return int2[0] !== unitRange[0] && int2[1] !== unitRange[1];
251-
});
252-
}
253-
s.startExtent = interval.region ? unitRange[interval.region === 's' ? 1 : 0] : unitLocation;
254-
d.parent.inBrushDrag = true;
255-
s.brushStartCallback();
256-
})
257-
.on('drag', function(d) {
258-
d3.event.sourceEvent.stopPropagation();
259-
var y = d.height - d3.mouse(this)[1] - 2 * c.verticalPadding;
260-
var s = d.brush.svgBrush;
261-
s.wasDragged = true;
262-
263-
if(s.grabbingBar) { // moving the bar
264-
s.newExtent = [y - s.grabPoint, y + s.barLength - s.grabPoint].map(d.unitToPaddedPx.invert);
265-
} else { // south/north drag or new bar creation
266-
s.newExtent = [s.startExtent, d.unitToPaddedPx.invert(y)].sort(sortAsc);
267-
}
268-
269-
d.brush.filterSpecified = true;
270-
s.extent = s.stayingIntervals.concat([s.newExtent]);
271-
s.brushCallback(d);
272-
renderHighlight(this.parentNode);
273-
})
274-
.on('dragend', function(d) {
275-
var e = d3.event;
276-
e.sourceEvent.stopPropagation();
277-
var brush = d.brush;
278-
var filter = brush.filter;
279-
var s = brush.svgBrush;
280-
var grabbingBar = s.grabbingBar;
281-
s.grabbingBar = false;
282-
s.grabLocation = undefined;
283-
d.parent.inBrushDrag = false;
284-
clearCursor(); // instead of clearing, a nicer thing would be to set it according to current location
285-
if(!s.wasDragged) { // a click+release on the same spot (ie. w/o dragging) means a bar or full reset
286-
s.wasDragged = undefined; // logic-wise unneeded, just shows `wasDragged` has no longer a meaning
287-
if(s.clickableOrdinalRange) {
288-
if(brush.filterSpecified && d.multiselect) {
289-
s.extent.push(s.clickableOrdinalRange);
290-
} else {
291-
s.extent = [s.clickableOrdinalRange];
292-
brush.filterSpecified = true;
293-
}
294-
} else if(grabbingBar) {
295-
s.extent = s.stayingIntervals;
296-
if(s.extent.length === 0) {
297-
brushClear(brush);
298-
}
299-
} else {
300-
brushClear(brush);
301-
}
302-
s.brushCallback(d);
303-
renderHighlight(this.parentNode);
304-
s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []);
305-
return; // no need to fuse intervals or snap to ordinals, so we can bail early
306-
}
307-
308-
var mergeIntervals = function() {
309-
// Key piece of logic: once the button is released, possibly overlapping intervals will be fused:
310-
// Here it's done immediately on click release while on ordinal snap transition it's done at the end
311-
filter.set(filter.getConsolidated());
312-
};
313-
314-
if(d.ordinal) {
315-
var a = d.unitTickvals;
316-
if(a[a.length - 1] < a[0]) a.reverse();
317-
s.newExtent = [
318-
ordinalScaleSnap(0, a, s.newExtent[0], s.stayingIntervals),
319-
ordinalScaleSnap(1, a, s.newExtent[1], s.stayingIntervals)
320-
];
321-
var hasNewExtent = s.newExtent[1] > s.newExtent[0];
322-
s.extent = s.stayingIntervals.concat(hasNewExtent ? [s.newExtent] : []);
323-
if(!s.extent.length) {
324-
brushClear(brush);
325-
}
326-
s.brushCallback(d);
327-
if(hasNewExtent) {
328-
// merging intervals post the snap tween
329-
renderHighlight(this.parentNode, mergeIntervals);
330-
} else {
331-
// if no new interval, don't animate, just redraw the highlight immediately
332-
mergeIntervals();
333-
renderHighlight(this.parentNode);
334-
}
335-
} else {
336-
mergeIntervals(); // merging intervals immediately
337-
}
338-
s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []);
339-
})
356+
.on('dragstart', function(d) { dragstart(this, d); })
357+
.on('drag', function(d) { drag(this, d); })
358+
.on('dragend', function(d) { dragend(this, d); })
340359
);
341360
}
342361

test/jasmine/tests/parcoords_test.js

+72
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ function mouseTo(x, y) {
4040
mouseEvent('mouseover', x, y);
4141
}
4242

43+
function mouseClick(x, y) {
44+
mouseTo(x, y);
45+
mouseEvent('mousedown', x, y);
46+
mouseEvent('mouseup', x, y);
47+
}
48+
4349
function mostOfDrag(x1, y1, x2, y2) {
4450
mouseTo(x1, y1);
4551
mouseEvent('mousedown', x1, y1);
@@ -1625,3 +1631,69 @@ describe('parcoords constraint interactions - with defined axis ranges', functio
16251631
.then(done);
16261632
});
16271633
});
1634+
1635+
describe('parcoords constraint click interactions - with pre-defined constraint ranges', function() {
1636+
function initialFigure() {
1637+
return {
1638+
data: [{
1639+
type: 'parcoords',
1640+
dimensions: [{
1641+
values: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
1642+
}, {
1643+
values: [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6],
1644+
tickvals: [0, 1, 2, 3, 4, 5, 6],
1645+
ticktext: ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
1646+
constraintrange: [1, 2]
1647+
}]
1648+
}],
1649+
layout: {
1650+
width: 400,
1651+
height: 400,
1652+
margin: {t: 100, b: 100, l: 100, r: 100}
1653+
}
1654+
};
1655+
}
1656+
1657+
var gd;
1658+
var initialSnapDuration;
1659+
var shortenedSnapDuration = 20;
1660+
var snapDelay = 100;
1661+
beforeAll(function() {
1662+
initialSnapDuration = PC.bar.snapDuration;
1663+
PC.bar.snapDuration = shortenedSnapDuration;
1664+
});
1665+
1666+
afterAll(function() {
1667+
purgeGraphDiv();
1668+
PC.bar.snapDuration = initialSnapDuration;
1669+
});
1670+
1671+
beforeEach(function(done) {
1672+
var hasGD = !!gd;
1673+
if(!hasGD) gd = createGraphDiv();
1674+
1675+
Plotly.react(gd, initialFigure())
1676+
.catch(failTest)
1677+
.then(done);
1678+
});
1679+
1680+
it('@noCI @gl should not drop constraintrange on click', function(done) {
1681+
expect(gd._fullData[0].dimensions[1].constraintrange).toBeCloseToArray([0.75, 2.25]);
1682+
1683+
// click to add a new item to the selection
1684+
mouseClick(295, 200);
1685+
delay(snapDelay)()
1686+
.then(function() {
1687+
expect(gd._fullData[0].dimensions[1].constraintrange).toBeCloseToArray([[0.75, 2.25], [2.75, 3.25]]);
1688+
1689+
// click to deselect all
1690+
mouseClick(295, 205);
1691+
})
1692+
.then(delay(snapDelay)())
1693+
.then(function() {
1694+
expect(gd._fullData[0].dimensions[1].constraintrange).toEqual(undefined);
1695+
})
1696+
.catch(failTest)
1697+
.then(done);
1698+
});
1699+
});

0 commit comments

Comments
 (0)