|
1 | | -import {create, namespaces, pointer as pointerof, quickselect} from "d3"; |
| 1 | +import {create, namespaces, pointer as pointerof, quickselect, union} from "d3"; |
2 | 2 | import {identity, maybeFrameAnchor, maybeTuple} from "../options.js"; |
3 | 3 | import {Mark} from "../plot.js"; |
4 | 4 | import {selection} from "../selection.js"; |
5 | | -import {applyFrameAnchor} from "../style.js"; |
| 5 | +import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles} from "../style.js"; |
6 | 6 |
|
7 | 7 | const defaults = { |
8 | | - ariaLabel: "pointer" |
| 8 | + ariaLabel: "pointer", |
| 9 | + fill: "none", |
| 10 | + stroke: "#3b5fc0", |
| 11 | + strokeWidth: 1.5 |
9 | 12 | }; |
10 | 13 |
|
11 | 14 | export class Pointer extends Mark { |
@@ -36,28 +39,91 @@ export class Pointer extends Mark { |
36 | 39 | const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions; |
37 | 40 | const {mode, n, r} = this; |
38 | 41 | 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 |
41 | 46 |
|
42 | 47 | const g = create("svg:g") |
43 | 48 | .attr("fill", "none"); |
44 | 49 |
|
45 | 50 | const parent = g.append("g") |
46 | | - .attr("stroke", "#3b5fc0") |
47 | | - .attr("stroke-width", 1.5) |
| 51 | + .call(applyIndirectStyles, this) |
| 52 | + .call(applyDirectStyles, this) |
48 | 53 | .node(); |
49 | 54 |
|
| 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 | + |
50 | 99 | g.append("rect") |
51 | 100 | .attr("pointer-events", "all") |
52 | 101 | .attr("width", width + marginLeft + marginRight) |
53 | 102 | .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 | + } |
56 | 118 |
|
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); |
61 | 127 | let S = index; |
62 | 128 | switch (mode) { |
63 | 129 | case "xy": { |
@@ -112,43 +178,20 @@ export class Pointer extends Mark { |
112 | 178 | } |
113 | 179 | } |
114 | 180 |
|
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); |
146 | 190 | }) |
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); |
152 | 195 | }); |
153 | 196 |
|
154 | 197 | const node = g.node(); |
|
0 commit comments