Skip to content

Commit fa29935

Browse files
committed
persistent pointer selection
1 parent 01dc7d5 commit fa29935

File tree

2 files changed

+93
-50
lines changed

2 files changed

+93
-50
lines changed

src/marks/pointer.js

Lines changed: 92 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import {create, namespaces, pointer as pointerof, quickselect} from "d3";
1+
import {create, namespaces, pointer as pointerof, quickselect, union} from "d3";
22
import {identity, maybeFrameAnchor, maybeTuple} from "../options.js";
33
import {Mark} from "../plot.js";
44
import {selection} from "../selection.js";
5-
import {applyFrameAnchor} from "../style.js";
5+
import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles} from "../style.js";
66

77
const defaults = {
8-
ariaLabel: "pointer"
8+
ariaLabel: "pointer",
9+
fill: "none",
10+
stroke: "#3b5fc0",
11+
strokeWidth: 1.5
912
};
1013

1114
export class Pointer extends Mark {
@@ -36,28 +39,91 @@ export class Pointer extends Mark {
3639
const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
3740
const {mode, n, r} = this;
3841
const [cx, cy] = applyFrameAnchor(this, dimensions);
39-
const r2 = r * r;
40-
let C = [];
42+
const r2 = r * r; // the squared radius; to determine points in proximity to the pointer
43+
const down = new Set(); // the set of pointers that are currently down
44+
let C = []; // a sparse index from index[i] to an svg:circle element
45+
let P = null; // the persistent selection; a subset of index, or null
4146

4247
const g = create("svg:g")
4348
.attr("fill", "none");
4449

4550
const parent = g.append("g")
46-
.attr("stroke", "#3b5fc0")
47-
.attr("stroke-width", 1.5)
51+
.call(applyIndirectStyles, this)
52+
.call(applyDirectStyles, this)
4853
.node();
4954

55+
// Renders the given logical selection S, a subset of index. Applies
56+
// copy-on-write to the array of circles C. Returns true if the selection
57+
// changed, and false otherwise.
58+
function render(S) {
59+
const SC = [];
60+
let changed = false;
61+
62+
// Enter (append) the newly-selected elements. The order of the circles is
63+
// arbitrary, with the most recently selected datum on top.
64+
S.forEach(i => {
65+
let c = C[i];
66+
if (!c) {
67+
c = document.createElementNS(namespaces.svg, "circle");
68+
c.setAttribute("id", i);
69+
c.setAttribute("r", 4);
70+
c.setAttribute("cx", X ? X[i] : cx);
71+
c.setAttribute("cy", Y ? Y[i] : cy);
72+
parent.appendChild(c);
73+
changed = true;
74+
}
75+
SC[i] = c;
76+
});
77+
78+
// Exit (remove) the no-longer-selected elements.
79+
C.forEach((c, i) => {
80+
if (!SC[i]) {
81+
c.remove();
82+
changed = true;
83+
}
84+
});
85+
86+
if (changed) C = SC;
87+
return changed;
88+
}
89+
90+
// Selects the given logical selection S, a subset of index, or null if
91+
// there is no selection.
92+
function select(S) {
93+
if (S === null) render([]);
94+
else if (!render(S)) return;
95+
node[selection] = S;
96+
node.dispatchEvent(new Event("input", {bubbles: true}));
97+
}
98+
5099
g.append("rect")
51100
.attr("pointer-events", "all")
52101
.attr("width", width + marginLeft + marginRight)
53102
.attr("height", height + marginTop + marginBottom)
54-
.on("pointerover pointermove", (event) => {
55-
const [mx, my] = pointerof(event);
103+
.on("pointerdown pointerover pointermove", event => {
104+
105+
// On pointerdown, initiate a new persistent selection, P, or extend
106+
// the existing persistent selection if the shift key is down; then
107+
// add to P for as long as the pointer remains down. If there is no
108+
// existing persistent selection on pointerdown, initialize P to the
109+
// empty selection rather than the points near the pointer such that
110+
// you can clear the persistent selection with a pointerdown followed
111+
// by a pointerup. (See below.)
112+
if (event.type === "pointerdown") {
113+
const nop = !P;
114+
down.add(event.pointerId);
115+
if (nop || !event.shiftKey) P = [];
116+
if (!nop && !event.shiftKey) return select(P);
117+
}
56118

57-
// Compute the selection index S: the subset of index that is
58-
// logically selected. Note that while normally this should be an
59-
// in-order subset of index, it isn’t here if the n option is
60-
// specified because quickselect will reorder in-place!
119+
// If any pointer is down, only consider pointers that are down.
120+
if (P && !down.has(event.pointerId)) return;
121+
122+
// Compute the current selection, S: the subset of index that is
123+
// logically selected. Normally this should be an in-order subset of
124+
// index, but it isn’t here because quickselect will reorder in-place
125+
// if the n option is used!
126+
const [mx, my] = pointerof(event);
61127
let S = index;
62128
switch (mode) {
63129
case "xy": {
@@ -112,43 +178,20 @@ export class Pointer extends Mark {
112178
}
113179
}
114180

115-
// Add a circle for any newly-selected datum; remove a circle for any
116-
// no-longer-selected datum. The order of these elements is arbitrary,
117-
// with the most recently selected datum on top.
118-
let C2 = [];
119-
let changed = false;
120-
S.forEach(i => {
121-
let c = C[i];
122-
if (!c) {
123-
c = document.createElementNS(namespaces.svg, "circle");
124-
c.setAttribute("id", i);
125-
c.setAttribute("r", 4);
126-
c.setAttribute("cx", X ? X[i] : cx);
127-
c.setAttribute("cy", Y ? Y[i] : cy);
128-
parent.appendChild(c);
129-
changed = true;
130-
}
131-
C2[i] = c;
132-
});
133-
C.forEach((c, i) => {
134-
if (!C2[i]) {
135-
c.remove();
136-
changed = true;
137-
}
138-
});
139-
C = C2;
140-
141-
// If the selection changed, emit an input event.
142-
if (changed) {
143-
node[selection] = S;
144-
node.dispatchEvent(new Event("input", {bubbles: true}));
145-
}
181+
// If there is a persistent selection, add the new selection to the
182+
// persistent selection; otherwise just use the current selection.
183+
select(P ? (P = Array.from(union(P, S))) : S);
184+
})
185+
.on("pointerup", event => {
186+
// On pointerup, if the selection is empty, clear the persistent to
187+
// selection to allow the ephemeral selection on subsequent hover.
188+
if (!P.length) select(P = null);
189+
down.delete(event.pointerId);
146190
})
147-
.on("pointerout", function() {
148-
C.forEach(c => c.remove());
149-
C = [];
150-
node[selection] = null;
151-
node.dispatchEvent(new Event("input", {bubbles: true}));
191+
.on("pointerout", () => {
192+
// On pointerout, if there is no persistent selection, clear the
193+
// ephemeral selection.
194+
if (!P) select(null);
152195
});
153196

154197
const node = g.node();

test/output/gistempAnomalyPointer.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1728,7 +1728,7 @@
17281728
<circle cx="620" cy="108.73239436619714" r="3" stroke="rgb(213, 95, 80)"></circle>
17291729
</g>
17301730
<g fill="none">
1731-
<g stroke="#3b5fc0" stroke-width="1.5"></g>
1731+
<g aria-label="pointer" fill="none" stroke="#3b5fc0" stroke-width="1.5"></g>
17321732
<rect pointer-events="all" width="700" height="450"></rect>
17331733
</g>
17341734
</svg><output>1644</output></span>

0 commit comments

Comments
 (0)