Skip to content

Commit 367e294

Browse files
committed
parcoords constraint cleanup
- more precise mapping of constraints to data - which enables decreased filterEpsilon - only make the constraint mask texture once per render - and update it on subsequent renders, so we're not leaking THAT - let its height be independent of canvasHeight, and set at 2K - speed up mask generation by only unsetting, not unsetting and resetting - always overshoot ordinal values for clarity - when picking ordinal values, map closer to what your already showed instead of often jumping to cover an extra value
1 parent d25d261 commit 367e294

File tree

7 files changed

+155
-106
lines changed

7 files changed

+155
-106
lines changed

src/traces/parcoords/axisbrush.js

Lines changed: 55 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -45,43 +45,56 @@ function addFilterBarDefs(defs) {
4545
.attr('stroke-width', c.bar.strokeWidth);
4646
}
4747

48-
// these two could be unified at a slight loss of readability
49-
function ordinalScaleSnapLo(a, v) {
50-
var i, prevDiff, prevValue, diff, j;
51-
for(i = 0, prevDiff = Infinity, prevValue = a[0]; i < a.length; i++) {
52-
if(a[i] < v) j = i;
53-
diff = Math.abs(a[i] - v);
54-
if(diff > prevDiff) {
55-
return {value: prevValue, adjacent: j};
56-
}
57-
prevDiff = diff;
58-
prevValue = a[i];
48+
var snapRatio = c.bar.snapRatio;
49+
function snapOvershoot(v, vAdjacent) { return v * (1 - snapRatio) + vAdjacent * snapRatio; }
50+
51+
var snapClose = c.bar.snapClose;
52+
function closeToCovering(v, vAdjacent) { return v * (1 - snapClose) + vAdjacent * snapClose; }
53+
54+
// snap for the low end of a range on an ordinal scale
55+
// on an ordinal scale, always show some overshoot from the exact value,
56+
// so it's clear we're covering it
57+
// find the interval we're in, and snap to 1/4 the distance to the next
58+
// these two could be unified at a slight loss of readability / perf
59+
function ordinalScaleSnapLo(a, v, existingRanges) {
60+
if(overlappingExisting(v, existingRanges)) return v;
61+
62+
var aPrev = a[0];
63+
var aPrevPrev = aPrev;
64+
for(var i = 1; i < a.length; i++) {
65+
var aNext = a[i];
66+
67+
// very close to the previous - snap down to it
68+
if(v < closeToCovering(aPrev, aNext)) return snapOvershoot(aPrev, aPrevPrev);
69+
if(v < aNext || i === a.length - 1) return snapOvershoot(aNext, aPrev);
70+
71+
aPrevPrev = aPrev;
72+
aPrev = aNext;
5973
}
60-
return {value: a[a.length - 1], adjacent: j};
6174
}
6275

63-
function ordinalScaleSnapHi(a, v) {
64-
var i, prevDiff, prevValue, diff, j;
65-
for(i = a.length - 1, prevDiff = Infinity, prevValue = a[a.length - 1]; i >= 0; i--) {
66-
if(a[i] > v) j = i;
67-
diff = Math.abs(a[i] - v);
68-
if(diff > prevDiff) {
69-
return {value: prevValue, adjacent: j};
70-
}
71-
prevDiff = diff;
72-
prevValue = a[i];
76+
function ordinalScaleSnapHi(a, v, existingRanges) {
77+
if(overlappingExisting(v, existingRanges)) return v;
78+
79+
var aPrev = a[a.length - 1];
80+
var aPrevPrev = aPrev;
81+
for(var i = a.length - 2; i >= 0; i--) {
82+
var aNext = a[i];
83+
84+
// very close to the previous - snap down to it
85+
if(v > closeToCovering(aPrev, aNext)) return snapOvershoot(aPrev, aPrevPrev);
86+
if(v > aNext || i === a.length - 1) return snapOvershoot(aNext, aPrev);
87+
88+
aPrevPrev = aPrev;
89+
aPrev = aNext;
7390
}
74-
return {value: a[0], adjacent: j};
7591
}
7692

77-
function snapOvershoot(a, i, value) {
78-
// Take the distance to the adjacent ordinal value (if any) and extend the magenta bar to some ratio of it,
79-
// so that singular value selections still show up with non-zero length (therefore visible and interactive) bars,
80-
// yet the extension is short enough that a gap remains between adjacent selections, irrespective of how many
81-
// ordinal values there are, and whether their cadence is regular or not. A dense clustering (screen distance of a
82-
// few pixels or less) is still bound to result in undecipherable highlighting due to the limits of the screen
83-
// resolution. Further doc is at the respective constants.
84-
return (i === undefined ? c.bar.snapDefaultRatio : Math.abs(value - a[i]) * c.bar.snapRatio);
93+
function overlappingExisting(v, existingRanges) {
94+
for(var i = 0; i < existingRanges.length; i++) {
95+
if(v >= existingRanges[i][0] && v <= existingRanges[i][1]) return true;
96+
}
97+
return false;
8598
}
8699

87100
function barHorizontalSetup(selection) {
@@ -282,9 +295,10 @@ function attachDragBehavior(selection) {
282295
if(s.grabbingBar) { // moving the bar
283296
s.newExtent = [y - s.grabPoint, y + s.barLength - s.grabPoint].map(d.unitScaleInOrder.invert);
284297
} else { // south/north drag or new bar creation
285-
s.newExtent = d.unitScaleInOrder(s.startExtent) < y
286-
? [s.startExtent, d.unitScaleInOrder.invert(y)]
287-
: [d.unitScaleInOrder.invert(y), s.startExtent];
298+
var endExtent = d.unitScaleInOrder.invert(y);
299+
s.newExtent = s.startExtent < endExtent ?
300+
[s.startExtent, endExtent] :
301+
[endExtent, s.startExtent];
288302
}
289303

290304
// take care of the parcoords axis height constraint: bar can't breach it
@@ -338,18 +352,14 @@ function attachDragBehavior(selection) {
338352
};
339353
if(d.ordinal) {
340354
var a = d.ordinalScale.range();
341-
var snapLo = ordinalScaleSnapLo(a, s.newExtent[0]);
342-
var snapHi = ordinalScaleSnapHi(a, s.newExtent[1]);
343-
var i0 = snapLo.adjacent;
344-
var i1 = snapHi.adjacent;
345-
s.newExtent[0] = snapLo.value;
346-
s.newExtent[1] = snapHi.value;
347-
if(s.newExtent[0] === s.newExtent[1]) {
348-
// if one single ordinal is selected, give it a finite bar length
349-
s.newExtent[0] = Math.max(0, s.newExtent[0] - snapOvershoot(a, i0, snapLo.value));
350-
s.newExtent[1] = Math.min(1, s.newExtent[1] + snapOvershoot(a, i1, snapHi.value));
355+
s.newExtent = [
356+
ordinalScaleSnapLo(a, s.newExtent[0], s.stayingIntervals),
357+
ordinalScaleSnapHi(a, s.newExtent[1], s.stayingIntervals)
358+
];
359+
s.extent = s.stayingIntervals.concat(s.newExtent[1] > s.newExtent[0] ? [s.newExtent] : []);
360+
if(!s.extent.length) {
361+
brushClear(brush);
351362
}
352-
s.extent = s.stayingIntervals.concat([s.newExtent]);
353363
s.brushCallback(d);
354364
renderHighlight(this.parentElement, mergeIntervals); // merging intervals post the snap tween
355365
} else {

src/traces/parcoords/constants.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ module.exports = {
2626
fillOpacity: 1, // Filter bar fill opacity
2727
snapDuration: 150, // tween duration in ms for brush snap for ordinal axes
2828
snapRatio: 0.25, // ratio of bar extension relative to the distance between two adjacent ordinal values
29-
snapDefaultRatio: 0.05, // bar extension relative to the entire axis length, for when there's no adjacent value
29+
snapClose: 0.01, // fraction of inter-value distance to snap to the closer one, even if you're not over it
3030
strokeColor: 'white', // Color of the filter bar side lines
3131
strokeOpacity: 1, // Filter bar side stroke opacity
3232
strokeWidth: 1, // Filter bar side stroke width in pixels

src/traces/parcoords/lines.js

Lines changed: 89 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,22 @@
99
'use strict';
1010

1111
var glslify = require('glslify');
12-
var c = require('./constants');
1312
var vertexShaderSource = glslify('./shaders/vertex.glsl');
1413
var contextShaderSource = glslify('./shaders/context_vertex.glsl');
1514
var pickVertexShaderSource = glslify('./shaders/pick_vertex.glsl');
1615
var fragmentShaderSource = glslify('./shaders/fragment.glsl');
1716

18-
var depthLimitEpsilon = 1e-6; // don't change; otherwise near/far plane lines are lost
17+
var Lib = require('../../lib');
18+
19+
// don't change; otherwise near/far plane lines are lost
20+
var depthLimitEpsilon = 1e-6;
21+
// just enough buffer for an extra bit at single-precision floating point
22+
// which on [0, 1] is 6e-8 (1/2^24)
23+
var filterEpsilon = 1e-7;
24+
25+
// precision of multiselect is the full range divided into this many parts
26+
var maskHeight = 2048;
27+
1928

2029
var gpuDimensionCount = 64;
2130
var sectionVertexCount = 2;
@@ -206,6 +215,8 @@ module.exports = function(canvasGL, d) {
206215

207216
var regl = d.regl;
208217

218+
var mask, maskTexture;
219+
209220
var paletteTexture = regl.texture({
210221
shape: [256, 1],
211222
format: 'rgba',
@@ -295,6 +306,7 @@ module.exports = function(canvasGL, d) {
295306
hiD: regl.prop('hiD'),
296307
palette: paletteTexture,
297308
mask: regl.prop('maskTexture'),
309+
maskHeight: regl.prop('maskHeight'),
298310
colorClamp: regl.prop('colorClamp')
299311
},
300312
offset: regl.prop('offset'),
@@ -310,30 +322,22 @@ module.exports = function(canvasGL, d) {
310322

311323
var previousAxisOrder = [];
312324

313-
function makeItem(i, ii, x, y, panelSizeX, canvasPanelSizeY, crossfilterDimensionIndex, I, leftmost, rightmost) {
325+
function makeItem(i, ii, x, y, panelSizeX, canvasPanelSizeY, crossfilterDimensionIndex, I, leftmost, rightmost, constraints) {
314326
var loHi, abcd, d, index;
315327
var leftRight = [i, ii];
316-
var filterEpsilon = c.verticalPadding / canvasPanelSizeY;
317328

318329
var dims = [0, 1].map(function() {return [0, 1, 2, 3].map(function() {return new Float32Array(16);});});
319-
var lims = [0, 1].map(function() {return [0, 1, 2, 3].map(function() {return new Float32Array(16);});});
320330

321331
for(loHi = 0; loHi < 2; loHi++) {
322332
index = leftRight[loHi];
323333
for(abcd = 0; abcd < 4; abcd++) {
324334
for(d = 0; d < 16; d++) {
325-
var dimP = d + 16 * abcd;
326335
dims[loHi][abcd][d] = d + 16 * abcd === index ? 1 : 0;
327-
if(!context) {
328-
lims[loHi][abcd][d] = (valid(d, 16 * abcd, panelCount) ? initialDims[dimP === 0 ? 0 : 1 + ((dimP - 1) % (initialDims.length - 1))].brush.filter.getBounds()[loHi] : loHi) + (2 * loHi - 1) * filterEpsilon;
329-
}
330336
}
331337
}
332338
}
333339

334-
var mask, maskTexture;
335-
336-
var vm = {
340+
var vm = Lib.extendFlat({
337341
key: crossfilterDimensionIndex,
338342
resolution: [canvasWidth, canvasHeight],
339343
viewBoxPosition: [x + overdrag, y],
@@ -361,55 +365,86 @@ module.exports = function(canvasGL, d) {
361365
viewportY: model.pad.b + model.layoutHeight * domain.y[0],
362366
viewportWidth: canvasWidth,
363367
viewportHeight: canvasHeight
364-
};
368+
}, constraints);
365369

366-
if(!context) {
367-
mask = Array.apply(null, new Array(canvasHeight * channelCount)).map(function() {
368-
return 255;
369-
});
370-
for(var dimIndex = 0; dimIndex < dimensionCount; dimIndex++) {
371-
var bitIndex = dimIndex % bitsPerByte;
372-
var byteIndex = (dimIndex - bitIndex) / bitsPerByte;
373-
var bitMask = Math.pow(2, bitIndex);
374-
var dim = initialDims[dimIndex];
375-
var ranges = dim.brush.filter.get();
376-
if(ranges.length < 2) continue; // bail if the bounding box based filter is sufficient
377-
var pi, pixelRange;
378-
var boundingBox = dim.brush.filter.getBounds();
379-
pixelRange = boundingBox.map(dim.unitScaleInOrder);
380-
for(pi = Math.max(0, Math.floor(pixelRange[0])); pi <= Math.min(canvasHeight - 1, Math.ceil(pixelRange[1])); pi++) {
381-
mask[pi * channelCount + byteIndex] &= ~bitMask; // clear bits
382-
}
383-
for(var r = 0; r < ranges.length; r++) {
384-
pixelRange = ranges[r].map(dim.unitScaleInOrder);
385-
for(pi = Math.max(0, Math.floor(pixelRange[0])); pi <= Math.min(canvasHeight - 1, Math.ceil(pixelRange[1])); pi++) {
386-
mask[pi * channelCount + byteIndex] |= bitMask;
370+
return vm;
371+
}
372+
373+
function makeConstraints() {
374+
var loHi, abcd, d;
375+
376+
var lims = [0, 1].map(function() {return [0, 1, 2, 3].map(function() {return new Float32Array(16);});});
377+
378+
for(loHi = 0; loHi < 2; loHi++) {
379+
for(abcd = 0; abcd < 4; abcd++) {
380+
for(d = 0; d < 16; d++) {
381+
var dimP = d + 16 * abcd;
382+
var lim;
383+
if(valid(d, 16 * abcd, panelCount)) {
384+
var dimi = initialDims[dimP === 0 ? 0 : 1 + ((dimP - 1) % (initialDims.length - 1))];
385+
lim = dimi.brush.filter.getBounds()[loHi];
387386
}
387+
else lim = loHi;
388+
lims[loHi][abcd][d] = lim + (2 * loHi - 1) * filterEpsilon;
388389
}
389390
}
391+
}
390392

391-
maskTexture = regl.texture({
392-
shape: [channelCount, canvasHeight], // 8 units x 8 bits = 64 bits, just sufficient for the almost 64 dimensions we support
393-
format: 'alpha',
394-
type: 'uint8',
395-
mag: 'nearest',
396-
min: 'nearest',
397-
data: mask
398-
});
393+
var maskExpansion = maskHeight / canvasHeight;
399394

400-
vm.maskTexture = maskTexture;
395+
function expandedPixelRange(dim, bounds) {
396+
var originalPixelRange = bounds.map(dim.unitScaleInOrder);
397+
return [
398+
Math.max(0, Math.floor(originalPixelRange[0] * maskExpansion)),
399+
Math.min(maskHeight - 1, Math.ceil(originalPixelRange[1] * maskExpansion))
400+
];
401+
}
401402

402-
vm.loA = lims[0][0];
403-
vm.loB = lims[0][1];
404-
vm.loC = lims[0][2];
405-
vm.loD = lims[0][3];
406-
vm.hiA = lims[1][0];
407-
vm.hiB = lims[1][1];
408-
vm.hiC = lims[1][2];
409-
vm.hiD = lims[1][3];
403+
mask = Array.apply(null, new Array(maskHeight * channelCount)).map(function() {
404+
return 255;
405+
});
406+
for(var dimIndex = 0; dimIndex < dimensionCount; dimIndex++) {
407+
var bitIndex = dimIndex % bitsPerByte;
408+
var byteIndex = (dimIndex - bitIndex) / bitsPerByte;
409+
var bitMask = Math.pow(2, bitIndex);
410+
var dim = initialDims[dimIndex];
411+
var ranges = dim.brush.filter.get().sort(function(a, b) {return a[0] - b[0]; });
412+
if(ranges.length < 2) continue; // bail if the bounding box based filter is sufficient
413+
414+
var prevEnd = expandedPixelRange(dim, ranges[0])[1];
415+
for(var ri = 1; ri < ranges.length; ri++) {
416+
var nextRange = expandedPixelRange(dim, ranges[ri]);
417+
for(var pi = prevEnd + 1; pi < nextRange[0]; pi++) {
418+
mask[pi * channelCount + byteIndex] &= ~bitMask;
419+
}
420+
prevEnd = Math.max(prevEnd, nextRange[1]);
421+
}
410422
}
411423

412-
return vm;
424+
var textureData = {
425+
// 8 units x 8 bits = 64 bits, just sufficient for the almost 64 dimensions we support
426+
shape: [channelCount, maskHeight],
427+
format: 'alpha',
428+
type: 'uint8',
429+
mag: 'nearest',
430+
min: 'nearest',
431+
data: mask
432+
};
433+
if(maskTexture) maskTexture(textureData);
434+
else maskTexture = regl.texture(textureData);
435+
436+
return {
437+
maskTexture: maskTexture,
438+
maskHeight: maskHeight,
439+
loA: lims[0][0],
440+
loB: lims[0][1],
441+
loC: lims[0][2],
442+
loD: lims[0][3],
443+
hiA: lims[1][0],
444+
hiB: lims[1][1],
445+
hiC: lims[1][2],
446+
hiD: lims[1][3]
447+
};
413448
}
414449

415450
function renderGLParcoords(panels, setChanged, clearOnly) {
@@ -433,6 +468,7 @@ module.exports = function(canvasGL, d) {
433468
// clear canvas here, as the panel iteration below will not enter the loop body
434469
clear(regl, 0, 0, canvasWidth, canvasHeight);
435470
}
471+
var constraints = context ? {} : makeConstraints();
436472

437473
for(I = 0; I < panelCount; I++) {
438474
var panel = panels[I];
@@ -447,7 +483,7 @@ module.exports = function(canvasGL, d) {
447483
var xTo = x + panelSizeX;
448484
if(setChanged || !previousAxisOrder[i] || previousAxisOrder[i][0] !== x || previousAxisOrder[i][1] !== xTo) {
449485
previousAxisOrder[i] = [x, xTo];
450-
var item = makeItem(i, ii, x, y, panelSizeX, panelSizeY, dim1.crossfilterDimensionIndex, I, leftmost, rightmost);
486+
var item = makeItem(i, ii, x, y, panelSizeX, panelSizeY, dim1.crossfilterDimensionIndex, I, leftmost, rightmost, constraints);
451487
renderState.clearOnly = clearOnly;
452488
renderBlock(regl, glAes, renderState, setChanged ? lines.blockLineCount : sampleCount, sampleCount, item);
453489
}

src/traces/parcoords/parcoords.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ function viewModel(state, callbacks, model) {
182182

183183
var unitPad = c.verticalPadding / (height * canvasPixelRatio);
184184
var unitPadScale = (1 - 2 * unitPad);
185-
var paddedUnitScale = function(d) {return unitPad + unitPadScale * d;};
185+
function paddedUnitScale(d) { return unitPad + unitPadScale * d; }
186+
function invertPaddedUnitScale(d) { return (d - unitPad) / unitPadScale; }
186187
var uScaleInOrder = unitScaleInOrder(height, c.verticalPadding);
187188

188189
var viewModel = {
@@ -202,7 +203,7 @@ function viewModel(state, callbacks, model) {
202203
var uScale = unitScale(height, c.verticalPadding);
203204
var specifiedConstraint = dimension.constraintrange;
204205
var filterRangeSpecified = specifiedConstraint && specifiedConstraint.length > 0;
205-
var filterRange = filterRangeSpecified ? specifiedConstraint.map(function(d) {return d.map(domainToUnit);}) : [[0, 1]];
206+
var filterRange = filterRangeSpecified ? specifiedConstraint.map(function(d) {return d.map(domainToUnit).map(paddedUnitScale);}) : [[0, 1]];
206207
var brushMove = function() {
207208
var p = viewModel;
208209
p.focusLayer && p.focusLayer.render(p.panels, true);
@@ -257,7 +258,7 @@ function viewModel(state, callbacks, model) {
257258
var invScale = domainToUnit.invert;
258259

259260
// update gd.data as if a Plotly.restyle were fired
260-
var newRanges = f.map(function(r) {return r.map(invScale);});
261+
var newRanges = f.map(function(r) {return r.map(invertPaddedUnitScale).map(invScale);});
261262
callbacks.filterChanged(p.key, dimension._index, newRanges);
262263
}
263264
}

0 commit comments

Comments
 (0)