8000 multiple selections on parcoords axes by monfera · Pull Request #2415 · plotly/plotly.js · GitHub
[go: up one dir, main page]

Skip to content

multiple selections on parcoords axes #2415

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Mar 21, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9208cc3
- factored out brushing
monfera Feb 5, 2018
40b0c2b
- full refactor of shader code, DRY, optimizations etc.
monfera Mar 3, 2018
7f507c3
remove constraintrange from parcoords mocks
alexcjohnson Mar 6, 2018
d25d261
tweak the parcoords constraint grabber ranges
alexcjohnson Mar 9, 2018
367e294
parcoords constraint cleanup
alexcjohnson Mar 12, 2018
1030542
dimension.multiselect boolean attr
alexcjohnson Mar 12, 2018
c046397
Merge branch 'master' into parcoords-multiselect-squashed
alexcjohnson Mar 12, 2018
a4f4a5f
info_array dimensions='1-2'
alexcjohnson Mar 13, 2018
a78db76
finalize multiselect functionality
alexcjohnson Mar 15, 2018
01ba11e
:hocho: fdescribe
alexcjohnson Mar 15, 2018
68e9e48
lint axisbrush
alexcjohnson Mar 15, 2018
cb00901
fail -> failTest - fail is a jasmine global
alexcjohnson Mar 15, 2018
f2690ac
fix one more filter bug, refactor parcoords test to hopefully reuse c…
alexcjohnson Mar 15, 2018
ec44922
refactor more of parcoords test to use Plotly.react
alexcjohnson Mar 15, 2018
352a884
parentElement -> parentNode
alexcjohnson Mar 15, 2018
57085e1
shorten parcoords snap tweening during tests
alexcjohnson Mar 15, 2018
fff668a
@flaky on ordinal constraint snap test
alexcjohnson Mar 15, 2018
6fe2d79
I give up... @noCI my new tests
alexcjohnson Mar 15, 2018
2911ae6
:hocho: parcoords memory leak
alexcjohnson Mar 16, 2018
fa1a436
parcoords create/update pattern
alexcjohnson Mar 16, 2018
0e75c5a
parcoords test cleanup
alexcjohnson Mar 16, 2018
a7bd686
click to select an ordinal value
alexcjohnson Mar 17, 2018
6432b89
cleanup, and remove some obsolete code
alexcjohnson Mar 17, 2018
03d8779
tweaked parcoords mocks
alexcjohnson Mar 19, 2018
f147644
Revert "tweaked parcoords mocks"
alexcjohnson Mar 19, 2018
c3cc927
fix bug introduced in cleanup
alexcjohnson Mar 19, 2018
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
Prev Previous commit
Next Next commit
finalize multiselect functionality
- use new constraintrange info_array 1-2 dimension format
- add multiselect attribute
- some further cleanup of conversions
- image test to cover most of multiselect logic & precision
- interaction tests of ordinal and continuous multiselect
- put constraint ranges back into existing mocks
  • Loading branch information
alexcjohnson committed Mar 15, 2018
commit a78db76b08bc8a5470ebc63c8e84c17e90221ea3
23 changes: 6 additions & 17 deletions src/traces/parcoords/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,17 @@ module.exports = {
constraintrange: {
valType: 'info_array',
role: 'info',
freeLength: true,
dimensions: '1-2',
items: [
{
valType: 'info_array',
editType: 'calc',
items: [
{valType: 'number', editType: 'calc'},
{valType: 'number', editType: 'calc'}
]
},
{
valType: 'info_array',
editType: 'calc',
items: [
{valType: 'number', editType: 'calc'},
{valType: 'number', editType: 'calc'}
]
}
{valType: 'number', editType: 'calc'},
{valType: 'number', editType: 'calc'}
],
editType: 'calc',
description: [
'The domain range to which the filter on the dimension is constrained. Must be an array',
'of `[fromValue, toValue]` with finite numbers as elements.'
'of `[fromValue, toValue]` with `fromValue <= toValue`, or if `multiselect` is not',
'disabled, you may give an array of arrays, where each inner array is `[fromValue, toValue]`.'
].join(' ')
},
multiselect: {
Expand Down
80 changes: 63 additions & 17 deletions src/traces/parcoords/axisbrush.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var c = require('./constants');
var d3 = require('d3');
var keyFun = require('../../lib/gup').keyFun;
var repeat = require('../../lib/gup').repeat;
var sortAsc = require('../../lib').sorterAsc;

function addFilterBarDefs(defs) {
var filterBarPattern = defs.selectAll('#' + c.id.filterBarPattern)
Expand Down Expand Up @@ -115,8 +116,7 @@ function setHighlight(d) {
if(!filterActive(d.brush)) {
return '0 ' + d.height;
}
var unitRanges = d.brush.filter.getConsolidated();
var pixelRanges = unitRanges.map(function(pr) {return pr.map(d.unitScaleInOrder);});
var pixelRanges = unitToPx(d.brush.filter.getConsolidated(), d.height);
var dashArray = [0]; // we start with a 0 length selection as filter ranges are inclusive, not exclusive
var p, sectionHeight, iNext;
var currentGap = pixelRanges.length ? pixelRanges[0][0] : null;
Expand All @@ -138,6 +138,12 @@ function setHighlight(d) {
return dashArray;
}

function unitToPx(unitRanges, height) {
return unitRanges.map(function(pr) {
return pr.map(function(v) { return v * height; }).sort(sortAsc);
});
}

function differentInterval(int1) {
// An interval is different if the extents don't match, which is a safe test only because the intervals
// get consolidated anyway (ie. the identity of overlapping intervals won't be preserved; they get fused)
Expand Down Expand Up @@ -187,9 +193,9 @@ function renderHighlight(root, tweenCallback) {
styleHighlight(barToStyle);
}

function getInterval(b, unitScaleInOrder, y) {
function getInterval(b, height, y) {
var intervals = b.filter.getConsolidated();
var pixIntervals = intervals.map(function(interval) {return interval.map(unitScaleInOrder);});
var pixIntervals = unitToPx(intervals, height);
var hoveredInterval = NaN;
var previousInterval = NaN;
var nextInterval = NaN;
Expand Down Expand Up @@ -243,8 +249,8 @@ function attachDragBehavior(selection) {
if(d.parent.inBrushDrag) {
return;
}
var y = d.unitScaleInOrder(d.unitScale.invert(d3.mouse(this)[1] + c.verticalPadding));
var interval = getInterval(b, d.unitScaleInOrder, y);
var y = d.height - d3.mouse(this)[1] - 2 * c.verticalPadding;
var interval = getInterval(b, d.height, y);
d3.select(document.body)
.style('cursor', interval.n ? 'n-resize' : interval.s ? 's-resize' : !interval.m ? 'crosshair' : filterActive(b) ? 'ns-resize' : 'crosshair');
})
Expand All @@ -258,10 +264,10 @@ function attachDragBehavior(selection) {
.on('dragstart', function(d) {
var e = d3.event;
e.sourceEvent.stopPropagation();
var y = d.unitScaleInOrder(d.unitScale.invert(d3.mouse(this)[1] + c.verticalPadding));
var y = d.height - d3.mouse(this)[1] - 2 * c.verticalPadding;
var unitLocation = d.unitScaleInOrder.invert(y);
var b = d.brush;
var intData = getInterval(b, d.unitScaleInOrder, y);
var intData = getInterval(b, d.height, y);
var unitRange = intData.interval;
var pixelRange = unitRange.map(d.unitScaleInOrder);
var s = b.svgBrush;
Expand Down Expand Up @@ -346,13 +352,16 @@ function attachDragBehavior(selection) {
s.brushEndCallback(filter.get());
return; // no need to fuse intervals or snap to ordinals, so we can bail early
}

var mergeIntervals = function() {
// Key piece of logic: once the button is released, possibly overlapping intervals will be fused:
// Here it's done immediately on click release while on ordinal snap transition it's done at the end
filter.set(filter.getConsolidated());
};

if(d.ordinal) {
var a = d.ordinalScale.range();
var a = d.paddedUnitValues;
if(a[a.length - 1] < a[0]) a = a.slice().sort(sortAsc);
s.newExtent = [
ordinalScaleSnapLo(a, s.newExtent[0], s.stayingIntervals),
ordinalScaleSnapHi(a, s.newExtent[1], s.stayingIntervals)
Expand All @@ -366,11 +375,13 @@ function attachDragBehavior(selection) {
} else {
mergeIntervals(); // merging intervals immediately
}
s.brushEndCallback(filter.get());
s.brushEndCallback(filter.getConsolidated());
})
);
}

function startAsc(a, b) { return a[0] - b[0]; }

function renderAxisBrush(axisBrush) {

var background = axisBrush.selectAll('.background').data(repeat);
Expand Down Expand Up @@ -458,7 +469,7 @@ function axisBrushMoved(callback) {
function dedupeRealRanges(intervals) {
// Fuses elements of intervals if they overlap, yielding discontiguous intervals, results.length <= intervals.length
// Currently uses closed intervals, ie. dedupeRealRanges([[400, 800], [300, 400]]) -> [300, 800]
var queue = intervals.slice().sort(function(a, b) {return a[0] - b[0];}); // ordered by interval start
var queue = intervals.slice();
var result = [];
var currentInterval;
var current = queue.shift();
Expand All @@ -475,14 +486,20 @@ function dedupeRealRanges(intervals) {
function makeFilter() {
var filter = [];
var consolidated;
var bounds;
return {
set: function(a) {
filter = a.slice().map(function(d) {return d.slice();});
consolidated = dedupeRealRanges(a);
filter = a
.map(function(d) { return d.slice().sort(sortAsc); })
.sort(startAsc);
consolidated = dedupeRealRanges(filter);
bounds = filter.reduce(function(p, n) {
return [Math.min(p[0], n[0]), Math.max(p[1], n[1])];
}, [Infinity, -Infinity]);
},
get: function() {return filter.slice();},
getConsolidated: function() {return consolidated;}, // would be nice if slow to slice in two layers...
getBounds: function() {return filter.reduce(function(p, n) {return [Math.min(p[0], n[0]), Math.max(p[1], n[1])];}, [Infinity, -Infinity]);}
get: function() { return filter.slice(); },
getConsolidated: function() { return consolidated; },
getBounds: function() { return bounds; }
};
}

Expand All @@ -501,9 +518,38 @@ function makeBrush(state, rangeSpecified, initialRange, brushStartCallback, brus
};
}

// for use by supplyDefaults, but it needed tons of pieces from here so
// seemed to make more sense just to put the whole routine here
function cleanRanges(ranges, dimension) {
if(Array.isArray(ranges[0])) {
ranges = ranges.map(function(ri) { return ri.sort(sortAsc); });

if(!dimension.multiselect) ranges = [ranges[0]];
else ranges = dedupeRealRanges(ranges.sort(startAsc));
}
else ranges = [ranges.sort(sortAsc)];

// ordinal snapping
if(dimension.tickvals) {
var sortedTickVals = dimension.tickvals.slice().sort(sortAsc);
ranges = ranges.map(function(ri) {
var rSnapped = [
ordinalScaleSnapLo(sortedTickVals, ri[0], []),
ordinalScaleSnapHi(sortedTickVals, ri[1], [])
];
if(rSnapped[1] > rSnapped[0]) return rSnapped;
})
.filter(function(ri) { return ri; });

if(!ranges.length) return;
}
return ranges.length > 1 ? ranges : ranges[0];
}

module.exports = {
addFilterBarDefs: addFilterBarDefs,
makeBrush: makeBrush,
ensureAxisBrush: ensureAxisBrush,
filterActive: filterActive
filterActive: filterActive,
cleanRanges: cleanRanges
};
6 changes: 5 additions & 1 deletion src/traces/parcoords/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ var hasColorscale = require('../../components/colorscale/has_colorscale');
var colorscaleDefaults = require('../../components/colorscale/defaults');
var maxDimensionCount = require('./constants').maxDimensionCount;
var handleDomainDefaults = require('../../plots/domain').defaults;
var axisBrush = require('./axisbrush');

function handleLineDefaults(traceIn, traceOut, defaultColor, layout, coerce) {
var lineColor = coerce('line.color', defaultColor);
Expand Down Expand Up @@ -71,7 +72,10 @@ function dimensionsDefaults(traceIn, traceOut) {
coerce('range');

coerce('multiselect');
coerce('constraintrange');
var constraintRange = coerce('constraintrange');
if(constraintRange) {
dimensionOut.constraintrange = axisBrush.cleanRanges(constraintRange, dimensionOut);
}

commonLength = Math.min(commonLength, values.length);
}
Expand Down
19 changes: 6 additions & 13 deletions src/traces/parcoords/lines.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,6 @@ function makeAttributes(sampleCount, points) {
return attributes;
}

function valid(i, offset, panelCount) {
return i + offset <= panelCount;
}

module.exports = function(canvasGL, d) {
var model = d.model,
vm = d.viewModel,
Expand Down Expand Up @@ -380,23 +376,20 @@ module.exports = function(canvasGL, d) {
for(d = 0; d < 16; d++) {
var dimP = d + 16 * abcd;
var lim;
if(valid(d, 16 * abcd, panelCount)) {
var dimi = initialDims[dimP === 0 ? 0 : 1 + ((dimP - 1) % (initialDims.length - 1))];
lim = dimi.brush.filter.getBounds()[loHi];
if(dimP < initialDims.length) {
lim = initialDims[dimP].brush.filter.getBounds()[loHi];
}
else lim = loHi;
lims[loHi][abcd][d] = lim + (2 * loHi - 1) * filterEpsilon;
}
}
}

var maskExpansion = maskHeight / canvasHeight;

function expandedPixelRange(dim, bounds) {
var originalPixelRange = bounds.map(dim.unitScaleInOrder);
var maskHMinus = maskHeight - 1;
return [
Math.max(0, Math.floor(originalPixelRange[0] * maskExpansion)),
Math.min(maskHeight - 1, Math.ceil(originalPixelRange[1] * maskExpansion))
Math.max(0, Math.floor(bounds[0] * maskHMinus)),
Math.min(maskHMinus, Math.ceil(bounds[1] * maskHMinus))
];
}

Expand All @@ -408,7 +401,7 @@ module.exports = function(canvasGL, d) {
var byteIndex = (dimIndex - bitIndex) / bitsPerByte;
var bitMask = Math.pow(2, bitIndex);
var dim = initialDims[dimIndex];
var ranges = dim.brush.filter.get().sort(function(a, b) {return a[0] - b[0]; });
var ranges = dim.brush.filter.get();
if(ranges.length < 2) continue; // bail if the bounding box based filter is sufficient

var prevEnd = expandedPixelRange(dim, ranges[0])[1];
Expand Down
54 changes: 44 additions & 10 deletions src/traces/parcoords/parcoords.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,12 @@ function toText(formatter, texts) {
};
}

function domainScale(height, padding, dimension) {
function domainScale(height, padding, dimension, tickvals, ticktext) {
var extent = dimensionExtent(dimension);
var texts = dimension.ticktext;
return dimension.tickvals ?
return tickvals ?
d3.scale.ordinal()
.domain(dimension.tickvals.map(toText(d3.format(dimension.tickformat), texts)))
.range(dimension.tickvals
.domain(tickvals.map(toText(d3.format(dimension.tickformat), ticktext)))
.range(tickvals
.map(function(d) {return (d - extent[0]) / (extent[1] - extent[0]);})
.map(function(d) {return (height - 10000 padding + d * (padding - (height - padding)));})) :
d3.scale.linear()
Expand Down Expand Up @@ -207,6 +206,7 @@ function viewModel(state, callbacks, model) {
var uScale = unitScale(height, c.verticalPadding);
var specifiedConstraint = dimension.constraintrange;
var filterRangeSpecified = specifiedConstraint && specifiedConstraint.length > 0;
if(filterRangeSpecified && !Array.isArray(specifiedConstraint[0])) specifiedConstraint = [specifiedConstraint];
var filterRange = filterRangeSpecified ? specifiedConstraint.map(function(d) {return d.map(domainToUnit).map(paddedUnitScale);}) : [[0, 1]];
var brushMove = function() {
var p = viewModel;
Expand All @@ -226,13 +226,45 @@ function viewModel(state, callbacks, model) {
truncatedValues = truncatedValues.slice(0, dimension._length);
}

var tickvals = dimension.tickvals;
var ticktext;
function makeTickItem(v, i) { return {val: v, text: ticktext[i]}; }
function sortTickItem(a, b) { return a.val - b.val; }
if(Array.isArray(tickvals) && tickvals.length) {
ticktext = dimension.ticktext;

// ensure ticktext and tickvals have same length
if(!Array.isArray(ticktext) || !ticktext.length) {
ticktext = tickvals.map(d3.format(dimension.tickformat));
}
else if(ticktext.length > tickvals.length) {
ticktext = ticktext.slice(0, tickvals.length);
}
else if(tickvals.length > ticktext.length) {
tickvals = tickvals.slice(0, ticktext.length);
}

// check if we need to sort tickvals/ticktext
for(var j = 1; j < tickvals.length; j++) {
if(tickvals[j] < tickvals[j - 1]) {
var tickItems = tickvals.map(makeTickItem).sort(sortTickItem);
for(var k = 0; k < tickvals.length; k++) {
tickvals[k] = tickItems[k].val;
ticktext[k] = tickItems[k].text;
}
break;
}
}
}
else tickvals = undefined;

return {
key: key,
label: dimension.label,
tickFormat: dimension.tickformat,
tickvals: dimension.tickvals,
ticktext: dimension.ticktext,
ordinal: !!dimension.tickvals,
tickvals: tickvals,
ticktext: ticktext,
ordinal: !!tickvals,
multiselect: dimension.multiselect,
xIndex: i,
crossfilterDimensionIndex: i,
Expand All @@ -246,7 +278,7 @@ function viewModel(state, callbacks, model) {
// fixme remove the old unitScale
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unitScale is still being used for its inverse mapping due to how y values are arriving from the underlying d3 interaction callback calls, but could in theory be unified, simplifying the current two-step map eg. var y = d.unitScaleInOrder(d.unitScale.invert(d3.mouse(this)[1]));

unitScale: uScale,
unitScaleInOrder: uScaleInOrder,
domainScale: domainScale(height, c.verticalPadding, dimension),
domainScale: domainScale(height, c.verticalPadding, dimension, tickvals, ticktext),
ordinalScale: ordinalScale(dimension),
domainToUnitScale: domainToUnit,
parent: viewModel,
Expand All @@ -268,7 +300,9 @@ function viewModel(state, callbacks, model) {
var invScale = domainToUnit.invert;

// update gd.data as if a Plotly.restyle were fired
var newRanges = f.map(function(r) {return r.map(invertPaddedUnitScale).map(invScale);});
var newRanges = f.map(function(r) {
return r.map(invertPaddedUnitScale).map(invScale).sort(Lib.sorterAsc);
}).sort(function(a, b) { return a[0] - b[0]; });
callbacks.filterChanged(p.key, dimension._index, newRanges);
}
}
Expand Down
17 changes: 15 additions & 2 deletions src/traces/parcoords/plot.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,22 @@ module.exports = function plot(gd, cdparcoords) {
// without having to incur heavy UI blocking due to an actual `Plotly.restyle` call

var gdDimension = gdDimensionsOriginalOrder[i][originalDimensionIndex];
gdDimension.constraintrange = newRanges.map(function(r) {return r.slice();});
var newConstraints = newRanges.map(function(r) { return r.slice(); });
if(!newConstraints.length) {
delete gdDimension.constraintrange;
newConstraints = null;
}
else {
if(newConstraints.length === 1) newConstraints = newConstraints[0];
gdDimension.constraintrange = newConstraints;
// wrap in another array for restyle event data
newConstraints = [newConstraints];
}

gd.emit('plotly_restyle');
var restyleData = {};
var aStr = 'dimensions[' + originalDimensionIndex + '].constraintrange';
restyleData[aStr] = newConstraints;
gd.emit('plotly_restyle', [restyleData, [i]]);
};

var hover = function(eventData) {
Expand Down
Binary file modified test/image/baselines/gl2d_parcoords.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/gl2d_parcoords_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/gl2d_parcoords_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified test/image/baselines/gl2d_parcoords_large.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
0