Skip to content

Commit 84f6f92

Browse files
committed
lasso
1 parent 8f7f784 commit 84f6f92

File tree

5 files changed

+1346
-0
lines changed

5 files changed

+1346
-0
lines changed

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {Cell, cell, cellX, cellY} from "./marks/cell.js";
77
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
88
export {Frame, frame} from "./marks/frame.js";
99
export {Image, image} from "./marks/image.js";
10+
export {Lasso, lasso} from "./marks/lasso.js";
1011
export {Line, line, lineX, lineY} from "./marks/line.js";
1112
export {Link, link} from "./marks/link.js";
1213
export {Pointer, pointer, pointerX, pointerY} from "./marks/pointer.js";

src/marks/lasso.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {create, select, dispatch as dispatcher, line, pointer, polygonContains, curveNatural} from "d3";
2+
import {maybeTuple} from "../options.js";
3+
import {Mark} from "../plot.js";
4+
import {selection, selectionEquals} from "../selection.js";
5+
import {applyIndirectStyles} from "../style.js";
6+
7+
const defaults = {
8+
ariaLabel: "lasso",
9+
fill: "#777",
10+
fillOpacity: 0.3,
11+
stroke: "#666",
12+
strokeWidth: 2
13+
};
14+
15+
export class Lasso extends Mark {
16+
constructor(data, {x, y, ...options} = {}) {
17+
super(
18+
data,
19+
[
20+
{name: "x", value: x, scale: "x"},
21+
{name: "y", value: y, scale: "y"}
22+
],
23+
options,
24+
defaults
25+
);
26+
this.activeElement = null;
27+
}
28+
29+
// The lasso polygons follow the even-odd rule in css, matching the way
30+
// they are computed by polygonContains.
31+
render(index, scales, {x: X, y: Y}, dimensions) {
32+
const margin = 5;
33+
const {ariaLabel, ariaDescription, ariaHidden, fill, fillOpacity, stroke, strokeWidth} = this;
34+
const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
35+
36+
const path = line().curve(curveNatural);
37+
const g = create("svg:g")
38+
.call(applyIndirectStyles, {ariaLabel, ariaDescription, ariaHidden, fill, fillOpacity, stroke, strokeWidth});
39+
g.append("rect")
40+
.attr("x", marginLeft)
41+
.attr("y", marginTop)
42+
.attr("width", width - marginLeft - marginRight)
43+
.attr("height", height - marginTop - marginBottom)
44+
.attr("fill", "none")
45+
.attr("cursor", "cross") // TODO
46+
.attr("pointer-events", "all")
47+
.attr("fill-rule", "evenodd");
48+
49+
g.call(lassoer()
50+
.extent([[marginLeft - margin, marginTop - margin], [width - marginRight + margin, height - marginBottom + margin]])
51+
.on("start lasso end cancel", (polygons) => {
52+
g.selectAll("path")
53+
.data(polygons)
54+
.join("path")
55+
.attr("d", path);
56+
const activePolygons = polygons.find(polygon => polygon.length > 2);
57+
const S = !activePolygons ? null
58+
: index.filter(i => polygons.some(polygon => polygon.length > 2 && polygonContains(polygon, [X[i], Y[i]])));
59+
if (!selectionEquals(node[selection], S)) {
60+
node[selection] = S;
61+
node.dispatchEvent(new Event("input", {bubbles: true}));
62+
}
63+
}));
64+
const node = g.node();
65+
node[selection] = null;
66+
return node;
67+
}
68+
}
69+
70+
export function lasso(data, {x, y, ...options} = {}) {
71+
([x, y] = maybeTuple(x, y));
72+
return new Lasso(data, {...options, x, y});
73+
}
74+
75+
// set up listeners that will follow this gesture all along
76+
// (even outside the target canvas)
77+
// TODO: in a supporting file
78+
function trackPointer(e, { start, move, out, end }) {
79+
const tracker = {},
80+
id = (tracker.id = e.pointerId),
81+
target = e.target;
82+
tracker.point = pointer(e, target);
83+
target.setPointerCapture(id);
84+
85+
select(target)
86+
.on(`pointerup.${id} pointercancel.${id}`, e => {
87+
if (e.pointerId !== id) return;
88+
tracker.sourceEvent = e;
89+
select(target).on(`.${id}`, null);
90+
target.releasePointerCapture(id);
91+
end && end(tracker);
92+
})
93+
.on(`pointermove.${id}`, e => {
94+
if (e.pointerId !== id) return;
95+
tracker.sourceEvent = e;
96+
tracker.prev = tracker.point;
97+
tracker.point = pointer(e, target);
98+
move && move(tracker);
99+
})
100+
.on(`pointerout.${id}`, e => {
101+
if (e.pointerId !== id) return;
102+
tracker.sourceEvent = e;
103+
tracker.point = null;
104+
out && out(tracker);
105+
});
106+
107+
start && start(tracker);
108+
}
109+
110+
function lassoer() {
111+
const polygons = [];
112+
const dispatch = dispatcher("start", "lasso", "end", "cancel");
113+
let extent;
114+
const lasso = selection => {
115+
const node = selection.node();
116+
let currentPolygon;
117+
118+
selection
119+
.on("touchmove", e => e.preventDefault()) // prevent scrolling
120+
.on("pointerdown", e => {
121+
const p = pointer(e, node);
122+
for (let i = polygons.length - 1; i >= 0; --i) {
123+
if (polygonContains(polygons[i], p)) {
124+
polygons.splice(i, 1);
125+
dispatch.call("cancel", node, polygons);
126+
return;
127+
}
128+
}
129+
trackPointer(e, {
130+
start: p => {
131+
currentPolygon = [constrainExtent(p.point)];
132+
polygons.push(currentPolygon);
133+
dispatch.call("start", node, polygons);
134+
},
135+
move: p => {
136+
currentPolygon.push(constrainExtent(p.point));
137+
dispatch.call("lasso", node, polygons);
138+
},
139+
end: () => {
140+
dispatch.call("end", node, polygons);
141+
}
142+
});
143+
});
144+
};
145+
lasso.on = function(type, _) {
146+
return _ ? (dispatch.on(...arguments), lasso) : dispatch.on(...arguments);
147+
};
148+
lasso.extent = function(_) {
149+
return _ ? (extent = _, lasso) : extent;
150+
};
151+
152+
function constrainExtent(p) {
153+
if (!extent) return p;
154+
return [clamp(p[0], extent[0][0], extent[1][0]), clamp(p[1], extent[0][1], extent[1][1])];
155+
}
156+
157+
function clamp(x, a, b) {
158+
return x < a ? a : x > b ? b : x;
159+
}
160+
161+
return lasso;
162+
}

0 commit comments

Comments
 (0)