* lasso working

* break out invert into own function

* action

* add spatial dimension to crossfilter, in support of polygon lasso

* improve comments on new dimension API

* lasso vs zoom
This commit is contained in:
Colin Megill
2019-02-08 14:06:23 -05:00
committed by Charlotte Weaver
parent 2e9525741f
commit dbb3a309a9
8 changed files with 564 additions and 146 deletions

View File

@@ -187,11 +187,8 @@ describe("createObsDimensionMap", () => {
}
}
});
expect(dimensionMap[layoutDimensionName("X")]).toBeInstanceOf(
Crossfilter.ScalarDimension
);
expect(dimensionMap[layoutDimensionName("Y")]).toBeInstanceOf(
Crossfilter.ScalarDimension
expect(dimensionMap[layoutDimensionName("XY")]).toBeInstanceOf(
Crossfilter.SpatialDimension
);
});
});

View File

@@ -102,22 +102,20 @@ const someData = [
];
function groupReduce(data, valueMap, valueReduce, valueInit) {
return _
.reduce(
data,
(acc, value) => {
const k = valueMap(value);
let r = _.find(acc, o => o.key === k);
if (!r) {
r = { key: k, value: valueInit() };
acc.push(r);
}
r.value = valueReduce(r.value, value);
return acc;
},
[]
)
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
return _.reduce(
data,
(acc, value) => {
const k = valueMap(value);
let r = _.find(acc, o => o.key === k);
if (!r) {
r = { key: k, value: valueInit() };
acc.push(r);
}
r.value = valueReduce(r.value, value);
return acc;
},
[]
).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
}
function groupCount(data, map) {
@@ -139,7 +137,11 @@ describe("typedCrossfilter", () => {
expect(payments.size()).toEqual(someData.length);
expect(payments.all()).toEqual(someData);
const quantity = payments.dimension(r => r.quantity, Int32Array);
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
expect(quantity).toBeDefined();
expect(quantity.id()).toBeDefined();
@@ -150,10 +152,22 @@ describe("typedCrossfilter", () => {
test("filterAll and filterNone", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Float32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
expect(quantity).toBeDefined();
expect(tip).toBeDefined();
@@ -198,10 +212,22 @@ describe("typedCrossfilter", () => {
test("filterExact", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Float32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
quantity.filterExact(1);
expect(payments.countFiltered()).toEqual(
@@ -222,10 +248,22 @@ describe("typedCrossfilter", () => {
test("filterRange", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Float32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
tip.filterRange([0, 91]);
expect(payments.allFiltered()).toEqual(
@@ -251,10 +289,22 @@ describe("typedCrossfilter", () => {
test("filterEnum", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Float32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
type.filterEnum(["tab", "cash"]);
expect(payments.allFiltered()).toEqual(
@@ -274,15 +324,31 @@ describe("typedCrossfilter", () => {
test("more than 32 dimensions", () => {
expect(payments).toBeDefined();
const quantity = payments.dimension(r => r.quantity, Int32Array);
const tip = payments.dimension(r => r.tip, Float32Array);
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Float32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Float32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
// Create a bunch of fake dimensions to ensure we can handle > 32
let dimMap = {};
for (let i = 0; i < 65; i++) {
dimMap[i] = payments.dimension(r => Math.random(), Float32Array);
dimMap[i] = payments.dimension(
crossfilter.ScalarDimension,
r => Math.random(),
Float32Array
);
expect(dimMap[i]).toBeDefined();
expect(dimMap[i].id()).toBeDefined();
}
@@ -304,10 +370,22 @@ describe("typedCrossfilter", () => {
test("group, default mapping, default reducer, no filter", () => {
expect(payments).toBeDefined();
var quantity = payments.dimension(r => r.quantity, Int32Array);
var tip = payments.dimension(r => r.tip, Int32Array);
var type = payments.dimension(r => r.type, "enum");
var total = payments.dimension(r => r.total, Int32Array);
const quantity = payments.dimension(
crossfilter.ScalarDimension,
r => r.quantity,
Int32Array
);
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Int32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Int32Array
);
_.each(
{
@@ -331,9 +409,17 @@ describe("typedCrossfilter", () => {
// custom mapping in groups only works for scalar types. Enums do not
// currently implement it.
const tip = payments.dimension(r => r.tip, Int32Array);
const totalX10 = payments.dimension(r => r.total * 10, Int32Array);
const type = payments.dimension(r => r.type, "enum");
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Int32Array
);
const totalX10 = payments.dimension(
crossfilter.ScalarDimension,
r => r.total * 10,
Int32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
const paymentsByTip_A = tip.group();
const paymentsByTip_B = tip.group(r => 10 * r);
@@ -370,8 +456,12 @@ describe("typedCrossfilter", () => {
test("group, default map, custom reducer, no filters", () => {
expect(payments).toBeDefined();
const total = payments.dimension(r => r.total, Float32Array);
const type = payments.dimension(r => r.type, "enum");
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Float32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
const paymentsByTotal = total.group();
const paymentsByType = type.group();
@@ -407,9 +497,17 @@ describe("typedCrossfilter", () => {
expect(payments).toBeDefined();
const tip = payments.dimension(r => r.tip, Int32Array);
const total = payments.dimension(r => r.total, Int32Array);
const type = payments.dimension(r => r.type, "enum");
const tip = payments.dimension(
crossfilter.ScalarDimension,
r => r.tip,
Int32Array
);
const total = payments.dimension(
crossfilter.ScalarDimension,
r => r.total,
Int32Array
);
const type = payments.dimension(crossfilter.EnumDimension, r => r.type);
const paymentsByTip = tip.group();
const paymentsByTotal = total.group();

View File

@@ -40,7 +40,7 @@ class Graph extends React.Component {
this.state = {
svg: null,
brush: null,
mode: "brush"
mode: "lasso"
};
}
@@ -202,7 +202,9 @@ class Graph extends React.Component {
this.handleBrushSelectAction.bind(this),
this.handleBrushDeselectAction.bind(this),
responsive,
this.graphPaddingRight
this.graphPaddingRight,
this.handleLassoStart.bind(this),
this.handleLassoEnd.bind(this)
);
this.setState({ svg: newSvg, brush });
}
@@ -251,54 +253,54 @@ class Graph extends React.Component {
});
}
invertPoint(pin) {
const { responsive } = this.props;
const { regl, camera, offset } = this.state;
const gl = regl._gl;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const x = (2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y = 2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] * aspect + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
}
handleBrushSelectAction() {
/*
This conditional handles procedural brush deselect. Brush emits
an event on procedural deselect because it is move: null
This conditional handles procedural brush deselect. Brush emits
an event on procedural deselect because it is move: null
*/
const { camera, offset } = this.state;
const { dispatch, responsive } = this.props;
if (d3.event.sourceEvent !== null) {
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
*/
const s = d3.event.selection;
const gl = this.state.regl._gl;
/*
/*
event describing brush position:
@-------|
| |
| |
|-------@
*/
/*
No idea why d3 event scope works like this
but apparently
it does
https://bl.ocks.org/EfratVil/0e542f5fc426065dd1d4b6daaa345a9f
*/
const { dispatch } = this.props;
// get aspect ratio
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
// compute inverse view matrix
const inverse = mat4.invert([], camera.view());
// transform screen coordinates -> cell coordinates
const invert = pin => {
const x =
(2 * pin[0]) / (responsive.width - this.graphPaddingRight) - 1;
const y =
2 * (1 - pin[1] / (responsive.height - this.graphPaddingTop)) - 1;
const pout = [
x * inverse[14] * aspect + inverse[12],
y * inverse[14] + inverse[13]
];
return [(pout[0] + 1) / 2 + offset[0], (pout[1] + 1) / 2 + offset[1]];
};
if (d3.event.sourceEvent !== null) {
const s = d3.event.selection;
const brushCoords = {
northwest: invert([s[0][0], s[0][1]]),
southeast: invert([s[1][0], s[1][1]])
northwest: this.invertPoint([s[0][0], s[0][1]]),
southeast: this.invertPoint([s[1][0], s[1][1]])
};
dispatch({
@@ -330,6 +332,25 @@ class Graph extends React.Component {
}
}
handleLassoStart() {
const { dispatch } = this.props;
// reset selected points when starting a new polygon
// making it easier for the user to make the next selection
dispatch({
type: "lasso started"
});
}
// when a lasso is completed, filter to the points within the lasso polygon
handleLassoEnd(polygon) {
const { dispatch } = this.props;
dispatch({
type: "lasso selection",
polygon: polygon.map(xy => this.invertPoint(xy)) // transform the polygon
});
}
handleOpacityRangeChange(e) {
const { dispatch } = this.props;
dispatch({
@@ -412,13 +433,18 @@ class Graph extends React.Component {
</Tooltip>
<div>
<div className="bp3-button-group">
<Tooltip content="Lasso cells" position="left">
<Tooltip content="Lasso selection" position="left">
<Button
className="bp3-button bp3-icon-select"
type="button"
active={mode === "brush"}
className="bp3-button bp3-icon-polygon-filter"
active={mode === "lasso"}
onClick={() => {
this.setState({ mode: "brush" });
this.handleBrushDeselectAction();
// this.restartReglLoop();
this.setState({ mode: "lasso" });
}}
style={{
cursor: "pointer"
}}
/>
</Tooltip>
@@ -451,7 +477,7 @@ class Graph extends React.Component {
>
<div
style={{
display: mode === "brush" ? "inherit" : "none"
display: mode === "lasso" ? "inherit" : "none"
}}
id="graphAttachPoint"
/>

View File

@@ -0,0 +1,127 @@
// https://bl.ocks.org/pbeshai/8008075f9ce771ee8be39e8c38907570
import * as d3 from "d3";
const Lasso = () => {
const dispatch = d3.dispatch("start", "end");
const polygonToPath = polygon =>
`M${polygon.map(d => d.join(",")).join("L")}`;
const distance = (pt1, pt2) =>
Math.sqrt((pt2[0] - pt1[0]) ** 2 + (pt2[1] - pt1[1]) ** 2);
// distance last point has to be to first point before it auto closes when mouse is released
const closeDistance = 75;
const lasso = svg => {
let lassoPolygon;
let lassoPath;
let closePath;
const handleDragStart = () => {
lassoPolygon = [d3.mouse(svg.node())]; // current x y of mouse within element
if (lassoPath) {
lassoPath.remove();
}
lassoPath = g
.append("path")
.attr("fill", "#0bb")
.attr("fill-opacity", 0.1)
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3");
closePath = g
.append("line")
.attr("x2", lassoPolygon[0][0])
.attr("y2", lassoPolygon[0][1])
.attr("stroke", "#0bb")
.attr("stroke-dasharray", "3, 3")
.attr("opacity", 0);
dispatch.call("start", lasso, lassoPolygon);
};
const handleDrag = () => {
const point = d3.mouse(svg.node());
lassoPolygon.push(point);
lassoPath.attr("d", polygonToPath(lassoPolygon));
// indicate if we are within closing distance
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
closePath
.attr("x1", point[0])
.attr("y1", point[1])
.attr("opacity", 1);
} else {
closePath.attr("opacity", 0);
}
};
const handleDragEnd = () => {
// remove the close path
closePath.remove();
closePath = null;
// succesfully closed
if (
distance(lassoPolygon[0], lassoPolygon[lassoPolygon.length - 1]) <
closeDistance
) {
lassoPath.attr("d", `${polygonToPath(lassoPolygon)}Z`);
dispatch.call("end", lasso, lassoPolygon);
// otherwise cancel
} else {
lassoPath.remove();
lassoPath = null;
lassoPolygon = null;
}
};
// append a <g> with a rect
const g = svg.append("g").attr("class", "lasso-group");
const bbox = svg.node().getBoundingClientRect();
const area = g
.append("rect")
.attr("width", bbox.width)
.attr("height", bbox.height)
.attr("fill", "tomato")
.attr("opacity", 0);
const drag = d3
.drag()
.on("start", handleDragStart)
.on("drag", handleDrag)
.on("end", handleDragEnd);
area.call(drag);
lasso.reset = () => {
if (lassoPath) {
lassoPath.remove();
lassoPath = null;
}
lassoPolygon = null;
if (closePath) {
closePath.remove();
closePath = null;
}
};
};
lasso.on = (type, callback) => {
dispatch.on(type, callback);
return lasso;
};
return lasso;
};
export default Lasso;

View File

@@ -1,6 +1,7 @@
// jshint esversion: 6
import * as d3 from "d3";
import styles from "./graph.css";
import Lasso from "./setupLasso";
/******************************************
*******************************************
@@ -12,7 +13,9 @@ export default (
handleBrushSelectAction,
handleBrushDeselectAction,
responsive,
graphPaddingRight
graphPaddingRight,
handleLassoStart,
handleLassoEnd
) => {
const svg = d3
.select("#graphAttachPoint")
@@ -32,9 +35,16 @@ export default (
.attr("class", "graph_brush")
.call(brush);
const lassoInstance = Lasso()
.on("end", handleLassoEnd)
.on("start", handleLassoStart);
const lasso = svg.call(lassoInstance);
return {
svg,
brushContainer,
brush
brush,
lasso
};
};

View File

@@ -1,6 +1,8 @@
// jshint esversion: 6
import _ from "lodash";
import { polygonContains } from "d3";
import { World, kvCache, WorldUtil } from "../util/stateManager";
import parseRGB from "../util/parseRGB";
import Crossfilter from "../util/typedCrossfilter";
@@ -479,27 +481,36 @@ const Controls = (
User Events
*******************************/
case "graph brush selection change": {
state.dimensionMap[layoutDimensionName("X")].filterRange([
action.brushCoords.northwest[0],
action.brushCoords.southeast[0]
]);
state.dimensionMap[layoutDimensionName("Y")].filterRange([
action.brushCoords.southeast[1],
action.brushCoords.northwest[1]
]);
state.dimensionMap[layoutDimensionName("XY")].filterWithinRect(
action.brushCoords.northwest,
action.brushCoords.southeast
);
return {
...state,
graphBrushSelection: action.brushCoords
};
}
case "lasso deselect":
case "graph brush deselect": {
state.dimensionMap[layoutDimensionName("X")].filterAll();
state.dimensionMap[layoutDimensionName("Y")].filterAll();
state.dimensionMap[layoutDimensionName("XY")].filterAll();
return {
...state,
graphBrushSelection: null
};
}
case "lasso selection": {
const { polygon } = action;
const dXY = state.dimensionMap[layoutDimensionName("XY")];
if (polygon.length < 3) {
// single point or a line is not a polygon, and is therefore a deselect
dXY.filterAll();
} else {
dXY.filterWithinPolygon(polygon);
}
return {
...state
};
}
case "continuous metadata histogram brush": {
const name = makeContinuousDimensionName(
action.continuousNamespace,

View File

@@ -4,6 +4,7 @@ import _ from "lodash";
import * as kvCache from "./keyvalcache";
import summarizeAnnotations from "./summarizeAnnotations";
import { layoutDimensionName, obsAnnoDimensionName } from "../nameCreators";
import Crossfilter from "../typedCrossfilter";
import { sliceByIndex } from "../typedCrossfilter/util";
/*
@@ -231,7 +232,12 @@ export function createVarDimension(
crossfilter,
geneName
) {
return crossfilter.dimension(_worldVarDataCache[geneName], Float32Array);
// return crossfilter.dimension(_worldVarDataCache[geneName], Float32Array);
return crossfilter.dimension(
Crossfilter.ScalarDimension,
_worldVarDataCache[geneName],
Float32Array
);
}
export function createObsDimensionMap(crossfilter, world) {
@@ -246,9 +252,14 @@ export function createObsDimensionMap(crossfilter, world) {
.filter(anno => anno.name !== "name")
.transform((result, anno) => {
const dimType = deduceDimensionType(anno, anno.name);
// XXX if dimtype is a scalar, we may be able to do better?
if (dimType) {
if (dimType === "enum") {
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
Crossfilter.EnumDimension,
r => r[anno.name]
);
} else {
result[obsAnnoDimensionName(anno.name)] = crossfilter.dimension(
Crossfilter.ScalarDimension,
r => r[anno.name],
dimType
);
@@ -259,13 +270,10 @@ export function createObsDimensionMap(crossfilter, world) {
/*
Add crossfilter dimensions allowing filtering on layout
*/
dimensionMap[layoutDimensionName("X")] = crossfilter.dimension(
dimensionMap[layoutDimensionName("XY")] = crossfilter.dimension(
Crossfilter.SpatialDimension,
obsLayout.X,
Float32Array
);
dimensionMap[layoutDimensionName("Y")] = crossfilter.dimension(
obsLayout.Y,
Float32Array
obsLayout.Y
);
return dimensionMap;

View File

@@ -27,6 +27,8 @@ more complex API. In a few cases, elements of that API were incorporated.
https://github.com/square/crossfilter/
*/
// XXX replace
import { polygonContains } from "d3";
import PositiveIntervals from "./positiveIntervals";
import BitArray from "./bitArray";
@@ -66,14 +68,22 @@ class TypedCrossfilter {
return this.data;
}
dimension(value, valueArrayType) {
/*
Create a crossfilter dimension, upon which filtering (subselection) can
be done. Each dimension is typed, and has a particular set of filtering
semantics.
* ScalarDimension - backed by TypedArray values, supporting filtering
by value (within a value range, or one or more exact values)
* EnumDimension - backed by an enumeration (eg, strings, bools), filtering
by one or more enum categories.
* SpatialDimension - backed by 2D points, filter by containment within
various shapes (currently supports within Rectangle and within Polygon).
Call this method to create a dimension, passing arguments appropriate for
the dimension constructor.
*/
dimension(DimensionType, ...rest) {
const id = this.selection.allocDimension();
let dim;
if (valueArrayType === "enum") {
dim = new EnumDimension(value, this, id);
} else {
dim = new ScalarDimension(value, valueArrayType, this, id);
}
const dim = new DimensionType(this, id, ...rest);
this.filters.push({ id, dim });
dim.filterAll();
return dim;
@@ -115,13 +125,34 @@ class TypedCrossfilter {
}
}
// Base dimension type - value must be a scalar type (eg, int, float),
// and value array must be a TypedArray.
//
class ScalarDimension {
constructor(value, ValueArrayType, xfltr, id) {
// Base dimension type - not exported.
class _Dimension {
constructor(xfltr, id) {
this.crossfilter = xfltr;
this._id = id;
this.groups = [];
}
dispose() {
this.crossfilter._freeDimension(this._id);
return this;
}
id() {
return this._id;
}
_filterUpdate() {
this.crossfilter.updateTime += 1;
}
}
// Scalar dimension type - value must be a scalar type (eg, int, float),
// and value array must be a TypedArray.
//
class ScalarDimension extends _Dimension {
constructor(xfltr, id, value, ValueArrayType) {
super(xfltr, id);
// current selection filter, expressed as PostiveIntervals.
this.currentFilter = [];
@@ -151,9 +182,6 @@ class ScalarDimension {
// create sort index
this.index = makeSortIndex(array);
// groups, if any
this.groups = [];
}
_createValueArray(value, array) {
@@ -167,15 +195,6 @@ class ScalarDimension {
return larray;
}
dispose() {
this.crossfilter._freeDimension(this._id);
return this;
}
id() {
return this._id;
}
// Argument is an array of intervals indicating records newly selected/filtered
//
_updateFilters(newFilter) {
@@ -209,7 +228,7 @@ class ScalarDimension {
);
this.currentFilter = cNewFilter;
this.crossfilter.updateTime += 1;
this._filterUpdate();
}
// filter by value - exact match
@@ -355,8 +374,8 @@ class ScalarDimension {
// strings, which can be mapped into an fixed numeric range [0..n).
//
class EnumDimension extends ScalarDimension {
constructor(value, xfltr, id) {
super(value, Uint32Array, xfltr, id);
constructor(xfltr, id, value) {
super(xfltr, id, value, Uint32Array);
}
_createValueArray(value, array) {
@@ -408,6 +427,127 @@ class EnumDimension extends ScalarDimension {
}
}
/*
Super simple 2D spatial dimension, supporting basic "filter within"
operations.
*/
class SpatialDimension extends _Dimension {
constructor(xfltr, id, X, Y) {
super(xfltr, id);
if (X.length !== Y.length && X.length !== this.crossfilter.data.length) {
throw new RangeError(
"SpatialDimension values must have same dimensionality as crossfilter"
);
}
this.X = X;
this.Y = Y;
this.Xindex = makeSortIndex(X);
this.Yindex = makeSortIndex(Y);
}
filterAll() {
this.crossfilter.selection.selectAll(this._id);
this._filterUpdate();
}
filterNone() {
this.crossfilter.selection.deselectAll(this._id);
this._filterUpdate();
}
/*
this could be smarter, but we don't currently use it...
*/
filterWithinRect(northwest, southeast) {
const [x0, y0] = northwest;
const [x1, y1] = southeast;
const { X, Y } = this;
const seln = this.crossfilter.selection;
const { _id } = this;
seln.deselectAll(_id);
for (let i = 0, l = this.X.length; i < l; i += 1) {
const x = X[i];
const y = Y[i];
if (x0 <= x && x < x1 && y0 <= y && y < y1) {
seln.selectOne(_id, i);
}
}
this._filterUpdate();
}
/*
Relatively brute force filter by polygon. Polygon is array of points, where
each point is [x,y]. Eg, [[x0,y0], [x1,y1], ...].
Currently uses d3.polygonContains() to test for polygon inclusion, which itself
uses a ray casting (crossing number) algorithm. There are a series of optimizations
to make this faster:
* first sliced by X or Y, using an index on the axis
* then the polygon bounding box is used for trivial rejection
* then the polygon test is applied
*/
filterWithinPolygon(polygon) {
/* return bounding box of the polygon */
function polygonBoundingBox(pg) {
let minX = Number.MAX_VALUE;
let minY = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
let maxY = Number.MIN_VALUE;
for (let i = 0, l = pg.length; i < l; i += 1) {
const p = pg[i];
const x = p[0];
const y = p[1];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
return [minX, minY, maxX, maxY];
}
const [minX, minY, maxX, maxY] = polygonBoundingBox(polygon);
const { X, Y } = this;
let slice;
let index;
if (maxY - minY > maxX - minX) {
slice = [
lowerBoundIndirect(X, this.Xindex, minX, 0, X.length),
upperBoundIndirect(X, this.Xindex, maxX, 0, X.length)
];
index = this.Xindex;
} else {
slice = [
lowerBoundIndirect(Y, this.Yindex, minY, 0, Y.length),
upperBoundIndirect(Y, this.Yindex, maxY, 0, Y.length)
];
index = this.Yindex;
}
const seln = this.crossfilter.selection;
const { _id } = this;
const testWithin = polygonContains; // d3.polygonContains()
seln.deselectAll(_id);
for (let i = slice[0], e = slice[1]; i < e; i += 1) {
const rid = index[i];
const x = X[rid];
const y = Y[rid];
if (
minX <= x &&
x < maxX &&
minY <= y &&
y < maxY &&
testWithin(polygon, [x, y])
) {
seln.selectOne(_id, rid);
}
}
this._filterUpdate();
}
}
// Groups! Map/reduce
//
class ScalarGroup {
@@ -611,5 +751,6 @@ crossfilter.BitArray = BitArray;
crossfilter.TypedCrossfilter = TypedCrossfilter;
crossfilter.ScalarDimension = ScalarDimension;
crossfilter.EnumDimension = EnumDimension;
crossfilter.SpatialDimension = SpatialDimension;
export default crossfilter;