mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-09-19 10:58:10 +08:00
Lasso (#586)
* 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:
committed by
Charlotte Weaver
parent
2e9525741f
commit
dbb3a309a9
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
127
client/src/components/graph/setupLasso.js
Normal file
127
client/src/components/graph/setupLasso.js
Normal 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;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
31
client/src/reducers/controls.js
vendored
31
client/src/reducers/controls.js
vendored
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user