diff --git a/client/__tests__/util/stateManager/world.test.js b/client/__tests__/util/stateManager/world.test.js
index 187e51be..321ccafa 100644
--- a/client/__tests__/util/stateManager/world.test.js
+++ b/client/__tests__/util/stateManager/world.test.js
@@ -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
);
});
});
diff --git a/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js b/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js
index 27096861..6e72ce1a 100644
--- a/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js
+++ b/client/__tests__/util/typedCrossfilter/typedCrossfilter.test.js
@@ -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();
diff --git a/client/src/components/graph/graph.js b/client/src/components/graph/graph.js
index cc29a77a..3251dd3c 100644
--- a/client/src/components/graph/graph.js
+++ b/client/src/components/graph/graph.js
@@ -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 {
-
+
@@ -451,7 +477,7 @@ class Graph extends React.Component {
>
diff --git a/client/src/components/graph/setupLasso.js b/client/src/components/graph/setupLasso.js
new file mode 100644
index 00000000..241c005e
--- /dev/null
+++ b/client/src/components/graph/setupLasso.js
@@ -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 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;
diff --git a/client/src/components/graph/setupSVGandBrush.js b/client/src/components/graph/setupSVGandBrush.js
index f88e3f19..beb6850a 100644
--- a/client/src/components/graph/setupSVGandBrush.js
+++ b/client/src/components/graph/setupSVGandBrush.js
@@ -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
};
};
diff --git a/client/src/reducers/controls.js b/client/src/reducers/controls.js
index 864b948e..e2bd41fd 100644
--- a/client/src/reducers/controls.js
+++ b/client/src/reducers/controls.js
@@ -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,
diff --git a/client/src/util/stateManager/world.js b/client/src/util/stateManager/world.js
index 08cbea44..eb508097 100644
--- a/client/src/util/stateManager/world.js
+++ b/client/src/util/stateManager/world.js
@@ -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;
diff --git a/client/src/util/typedCrossfilter/index.js b/client/src/util/typedCrossfilter/index.js
index c5d2029e..2b1315d7 100644
--- a/client/src/util/typedCrossfilter/index.js
+++ b/client/src/util/typedCrossfilter/index.js
@@ -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;