diff --git a/src/index.js b/src/index.js
index 3d282cd381..ced844c56b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,13 +2,15 @@ export {plot, Mark, marks} from "./plot.js";
export {Area, area, areaX, areaY} from "./marks/area.js";
export {Arrow, arrow} from "./marks/arrow.js";
export {BarX, BarY, barX, barY} from "./marks/bar.js";
-export {brush, brushX, brushY} from "./marks/brush.js";
+export {Brush, brush, brushX, brushY} from "./marks/brush.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Dot, dot, dotX, dotY} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Image, image} from "./marks/image.js";
+export {Lasso, lasso} from "./marks/lasso.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
export {Link, link} from "./marks/link.js";
+export {Pointer, pointer, pointerX, pointerY} from "./marks/pointer.js";
export {Rect, rect, rectX, rectY} from "./marks/rect.js";
export {RuleX, RuleY, ruleX, ruleY} from "./marks/rule.js";
export {Text, text, textX, textY} from "./marks/text.js";
diff --git a/src/marks/brush.js b/src/marks/brush.js
index d319d6b732..ab7a24d06c 100644
--- a/src/marks/brush.js
+++ b/src/marks/brush.js
@@ -79,9 +79,9 @@ export function brush(data, {x, y, ...options} = {}) {
}
export function brushX(data, {x = identity, ...options} = {}) {
- return new Brush(data, {...options, x, y: null});
+ return new Brush(data, {...options, x});
}
export function brushY(data, {y = identity, ...options} = {}) {
- return new Brush(data, {...options, x: null, y});
+ return new Brush(data, {...options, y});
}
diff --git a/src/marks/lasso.js b/src/marks/lasso.js
new file mode 100644
index 0000000000..7a832187a7
--- /dev/null
+++ b/src/marks/lasso.js
@@ -0,0 +1,169 @@
+import {create, select, dispatch as dispatcher, line, pointer, polygonContains, curveNatural} from "d3";
+import {maybeTuple} from "../options.js";
+import {Mark} from "../plot.js";
+import {selection, selectionEquals} from "../selection.js";
+import {applyIndirectStyles} from "../style.js";
+
+const defaults = {
+ ariaLabel: "lasso",
+ fill: "#777",
+ fillOpacity: 0.3,
+ stroke: "#666",
+ strokeWidth: 2
+};
+
+export class Lasso extends Mark {
+ constructor(data, {x, y, ...options} = {}) {
+ super(
+ data,
+ [
+ {name: "x", value: x, scale: "x"},
+ {name: "y", value: y, scale: "y"}
+ ],
+ options,
+ defaults
+ );
+ this.activeElement = null;
+ }
+
+ // The lasso polygons follow the even-odd rule in css, matching the way
+ // they are computed by polygonContains.
+ render(index, {x, y}, {x: X, y: Y}, dimensions) {
+ const margin = 5;
+ const {ariaLabel, ariaDescription, ariaHidden, fill, fillOpacity, stroke, strokeWidth} = this;
+ const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
+
+ const path = line().curve(curveNatural);
+ const g = create("svg:g")
+ .call(applyIndirectStyles, {ariaLabel, ariaDescription, ariaHidden, fill, fillOpacity, stroke, strokeWidth});
+ g.append("rect")
+ .attr("x", marginLeft)
+ .attr("y", marginTop)
+ .attr("width", width - marginLeft - marginRight)
+ .attr("height", height - marginTop - marginBottom)
+ .attr("stroke", "none")
+ .attr("fill", "none")
+ .attr("cursor", "cross") // TODO
+ .attr("pointer-events", "all")
+ .attr("fill-rule", "evenodd");
+
+ g.call(lassoer()
+ .extent([[marginLeft - margin, marginTop - margin], [width - marginRight + margin, height - marginBottom + margin]])
+ .on("start lasso end cancel", (polygons) => {
+ g.selectAll("path")
+ .data(polygons)
+ .join("path")
+ .attr("d", path);
+ let S = null;
+ let activePolygons = polygons.filter(polygon => polygon.length > 2);
+ if (activePolygons.length > 0) {
+ let bw;
+ if (x.bandwidth && (bw = x.bandwidth() / 2)) activePolygons = activePolygons.map(polygon => polygon.map(p => [p[0] - bw, p[1]]));
+ if (y.bandwidth && (bw = y.bandwidth() / 2)) activePolygons = activePolygons.map(polygon => polygon.map(p => [p[0], p[1] - bw]));
+ S = index.filter(i => activePolygons.some(polygon => polygonContains(polygon, [X[i], Y[i]])));
+
+ }
+ if (!selectionEquals(node[selection], S)) {
+ node[selection] = S;
+ node.dispatchEvent(new Event("input", {bubbles: true}));
+ }
+ }));
+ const node = g.node();
+ node[selection] = null;
+ return node;
+ }
+}
+
+export function lasso(data, {x, y, ...options} = {}) {
+ ([x, y] = maybeTuple(x, y));
+ return new Lasso(data, {...options, x, y});
+}
+
+// set up listeners that will follow this gesture all along
+// (even outside the target canvas)
+// TODO: in a supporting file
+function trackPointer(e, { start, move, out, end }) {
+ const tracker = {},
+ id = (tracker.id = e.pointerId),
+ target = e.target;
+ tracker.point = pointer(e, target);
+ target.setPointerCapture(id);
+
+ select(target)
+ .on(`pointerup.${id} pointercancel.${id}`, e => {
+ if (e.pointerId !== id) return;
+ tracker.sourceEvent = e;
+ select(target).on(`.${id}`, null);
+ target.releasePointerCapture(id);
+ end && end(tracker);
+ })
+ .on(`pointermove.${id}`, e => {
+ if (e.pointerId !== id) return;
+ tracker.sourceEvent = e;
+ tracker.prev = tracker.point;
+ tracker.point = pointer(e, target);
+ move && move(tracker);
+ })
+ .on(`pointerout.${id}`, e => {
+ if (e.pointerId !== id) return;
+ tracker.sourceEvent = e;
+ tracker.point = null;
+ out && out(tracker);
+ });
+
+ start && start(tracker);
+}
+
+function lassoer() {
+ const polygons = [];
+ const dispatch = dispatcher("start", "lasso", "end", "cancel");
+ let extent;
+ const lasso = selection => {
+ const node = selection.node();
+ let currentPolygon;
+
+ selection
+ .on("touchmove", e => e.preventDefault()) // prevent scrolling
+ .on("pointerdown", e => {
+ const p = pointer(e, node);
+ for (let i = polygons.length - 1; i >= 0; --i) {
+ if (polygonContains(polygons[i], p)) {
+ polygons.splice(i, 1);
+ dispatch.call("cancel", node, polygons);
+ return;
+ }
+ }
+ trackPointer(e, {
+ start: p => {
+ currentPolygon = [constrainExtent(p.point)];
+ polygons.push(currentPolygon);
+ dispatch.call("start", node, polygons);
+ },
+ move: p => {
+ currentPolygon.push(constrainExtent(p.point));
+ dispatch.call("lasso", node, polygons);
+ },
+ end: () => {
+ dispatch.call("end", node, polygons);
+ }
+ });
+ });
+ };
+ lasso.on = function(type, _) {
+ return _ ? (dispatch.on(...arguments), lasso) : dispatch.on(...arguments);
+ };
+ lasso.extent = function(_) {
+ return _ ? (extent = _, lasso) : extent;
+ };
+
+ function constrainExtent(p) {
+ if (!extent) return p;
+ return [clamp(p[0], extent[0][0], extent[1][0]), clamp(p[1], extent[0][1], extent[1][1])];
+ }
+
+ function clamp(x, a, b) {
+ return x < a ? a : x > b ? b : x;
+ }
+
+ return lasso;
+}
\ No newline at end of file
diff --git a/src/marks/pointer.js b/src/marks/pointer.js
new file mode 100644
index 0000000000..08120f1f7c
--- /dev/null
+++ b/src/marks/pointer.js
@@ -0,0 +1,225 @@
+import {create, namespaces, pointer as pointerof, quickselect, union} from "d3";
+import {identity, maybeFrameAnchor, maybeTuple} from "../options.js";
+import {Mark} from "../plot.js";
+import {selection} from "../selection.js";
+import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles} from "../style.js";
+
+const defaults = {
+ ariaLabel: "pointer",
+ fill: "none",
+ stroke: "#3b5fc0",
+ strokeWidth: 1.5
+};
+
+export class Pointer extends Mark {
+ constructor(data, {
+ x,
+ y,
+ n = Infinity,
+ r = isFinite(n) ? 120 : 20,
+ mode = "auto",
+ frameAnchor,
+ ...options
+ } = {}) {
+ super(
+ data,
+ [
+ {name: "x", value: x, scale: "x", optional: true},
+ {name: "y", value: y, scale: "y", optional: true}
+ ],
+ options,
+ defaults
+ );
+ this.n = +n;
+ this.r = +r;
+ this.mode = maybeMode(mode, x, y);
+ this.frameAnchor = maybeFrameAnchor(frameAnchor);
+ }
+ render(index, scales, {x: X, y: Y}, dimensions) {
+ const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions;
+ const {mode, n, r} = this;
+ const [cx, cy] = applyFrameAnchor(this, dimensions);
+ const r2 = r * r; // the squared radius; to determine points in proximity to the pointer
+ const down = new Set(); // the set of pointers that are currently down
+ let C = []; // a sparse index from index[i] to an svg:circle element
+ let P = null; // the persistent selection; a subset of index, or null
+
+ const g = create("svg:g");
+
+ const parent = g.append("g")
+ .call(applyIndirectStyles, this)
+ .call(applyDirectStyles, this)
+ .node();
+
+ // Renders the given logical selection S, a subset of index. Applies
+ // copy-on-write to the array of circles C. Returns true if the selection
+ // changed, and false otherwise.
+ function render(S) {
+ const SC = [];
+ let changed = false;
+
+ // Enter (append) the newly-selected elements. The order of the circles is
+ // arbitrary, with the most recently selected datum on top.
+ S.forEach(i => {
+ let c = C[i];
+ if (!c) {
+ c = document.createElementNS(namespaces.svg, "circle");
+ c.setAttribute("id", i);
+ c.setAttribute("r", 4);
+ c.setAttribute("cx", X ? X[i] : cx);
+ c.setAttribute("cy", Y ? Y[i] : cy);
+ parent.appendChild(c);
+ changed = true;
+ }
+ SC[i] = c;
+ });
+
+ // Exit (remove) the no-longer-selected elements.
+ C.forEach((c, i) => {
+ if (!SC[i]) {
+ c.remove();
+ changed = true;
+ }
+ });
+
+ if (changed) C = SC;
+ return changed;
+ }
+
+ // Selects the given logical selection S, a subset of index, or null if
+ // there is no selection.
+ function select(S) {
+ if (S === null) render([]);
+ else if (!render(S)) return;
+ node[selection] = S;
+ node.dispatchEvent(new Event("input", {bubbles: true}));
+ }
+
+ g.append("rect")
+ .attr("fill", "none")
+ .attr("pointer-events", "all")
+ .attr("width", width + marginLeft + marginRight)
+ .attr("height", height + marginTop + marginBottom)
+ .on("pointerdown pointerover pointermove", event => {
+
+ // On pointerdown, initiate a new persistent selection, P, or extend
+ // the existing persistent selection if the shift key is down; then
+ // add to P for as long as the pointer remains down. If there is no
+ // existing persistent selection on pointerdown, initialize P to the
+ // empty selection rather than the points near the pointer such that
+ // you can clear the persistent selection with a pointerdown followed
+ // by a pointerup. (See below.)
+ if (event.type === "pointerdown") {
+ const nop = !P;
+ down.add(event.pointerId);
+ if (nop || !event.shiftKey) P = [];
+ if (!nop && !event.shiftKey) return select(P);
+ }
+
+ // If any pointer is down, only consider pointers that are down.
+ if (P && !down.has(event.pointerId)) return;
+
+ // Compute the current selection, S: the subset of index that is
+ // logically selected. Normally this should be an in-order subset of
+ // index, but it isn’t here because quickselect will reorder in-place
+ // if the n option is used!
+ const [mx, my] = pointerof(event);
+ let S = index;
+ switch (mode) {
+ case "xy": {
+ if (r < Infinity) {
+ S = S.filter(i => {
+ const dx = X[i] - mx, dy = Y[i] - my;
+ return dx * dx + dy * dy <= r2;
+ });
+ }
+ if (S.length > n) {
+ S = S.slice();
+ quickselect(S, n, undefined, undefined, (i, j) => {
+ const ix = X[i] - mx, iy = Y[i] - my;
+ const jx = X[j] - mx, jy = Y[j] - my;
+ return (ix * ix + iy * iy) - (jx * jx + jy * jy);
+ });
+ S = S.slice(0, n);
+ }
+ break;
+ }
+ case "x": {
+ if (r < Infinity) {
+ const [x0, x1] = [mx - r, mx + r];
+ S = S.filter(i => x0 <= X[i] && X[i] <= x1);
+ }
+ if (S.length > n) {
+ S = S.slice();
+ quickselect(S, n, undefined, undefined, (i, j) => {
+ const ix = X[i] - mx;
+ const jx = X[j] - mx;
+ return ix * ix - jx * jx;
+ });
+ S = S.slice(0, n);
+ }
+ break;
+ }
+ case "y": {
+ if (r < Infinity) {
+ const [y0, y1] = [my - r, my + r];
+ S = S.filter(i => y0 <= Y[i] && Y[i] <= y1);
+ }
+ if (S.length > n) {
+ S = S.slice();
+ quickselect(S, n, undefined, undefined, (i, j) => {
+ const iy = Y[i] - my;
+ const jy = Y[j] - my;
+ return iy * iy - jy * jy;
+ });
+ S = S.slice(0, n);
+ }
+ break;
+ }
+ }
+
+ // If there is a persistent selection, add the new selection to the
+ // persistent selection; otherwise just use the current selection.
+ select(P ? (P = Array.from(union(P, S))) : S);
+ })
+ .on("pointerup", event => {
+ // On pointerup, if the selection is empty, clear the persistent to
+ // selection to allow the ephemeral selection on subsequent hover.
+ if (!P.length) select(P = null);
+ down.delete(event.pointerId);
+ })
+ .on("pointerout", () => {
+ // On pointerout, if there is no persistent selection, clear the
+ // ephemeral selection.
+ if (!P) select(null);
+ });
+
+ const node = g.node();
+ node[selection] = null;
+ return node;
+ }
+}
+
+function maybeMode(mode = "auto", x, y) {
+ switch (mode = `${mode}`.toLowerCase()) {
+ case "auto": mode = y == null ? "x" : x == null ? "y" : "xy"; break;
+ case "x": case "y": case "xy": break;
+ default: throw new Error(`invalid mode: ${mode}`);
+ }
+ if (/^x/.test(mode) && x == null) throw new Error("missing channel: x");
+ if (/y$/.test(mode) && y == null) throw new Error("missing channel: y");
+ return mode;
+}
+
+export function pointer(data, {x, y, ...options} = {}) {
+ ([x, y] = maybeTuple(x, y));
+ return new Pointer(data, {...options, x, y});
+}
+
+export function pointerX(data, {mode = "x", x = identity, ...options} = {}) {
+ return new Pointer(data, {...options, mode, x});
+}
+
+export function pointerY(data, {mode = "y", y = identity, ...options} = {}) {
+ return new Pointer(data, {...options, mode, y});
+}
diff --git a/test/output/gistempAnomalyBrush.html b/test/output/gistempAnomalyBrush.html
new file mode 100644
index 0000000000..9f33ad8da9
--- /dev/null
+++ b/test/output/gistempAnomalyBrush.html
@@ -0,0 +1,1742 @@
+
\ No newline at end of file
diff --git a/test/output/gistempAnomalyBrush.svg b/test/output/gistempAnomalyBrush.svg
deleted file mode 100644
index 4f7b3e8ac3..0000000000
--- a/test/output/gistempAnomalyBrush.svg
+++ /dev/null
@@ -1,1742 +0,0 @@
-
\ No newline at end of file
diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html
new file mode 100644
index 0000000000..f86c9ef973
--- /dev/null
+++ b/test/output/gistempAnomalyPointer.html
@@ -0,0 +1,1734 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenBrushX.html b/test/output/penguinCulmenBrushX.html
new file mode 100644
index 0000000000..e499d3780b
--- /dev/null
+++ b/test/output/penguinCulmenBrushX.html
@@ -0,0 +1,2988 @@
+
\ No newline at end of file
diff --git a/test/output/penguinCulmenLasso.html b/test/output/penguinCulmenLasso.html
new file mode 100644
index 0000000000..a4ca0c096e
--- /dev/null
+++ b/test/output/penguinCulmenLasso.html
@@ -0,0 +1,1143 @@
+
\ No newline at end of file
diff --git a/test/plots/gistemp-anomaly-brush.js b/test/plots/gistemp-anomaly-brush.js
index 3a3b2f97d8..cad771c8a7 100644
--- a/test/plots/gistemp-anomaly-brush.js
+++ b/test/plots/gistemp-anomaly-brush.js
@@ -1,9 +1,10 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
+import {html} from "htl";
export default async function() {
const data = await d3.csv("data/gistemp.csv", d3.autoType);
- return Plot.plot({
+ const plot = Plot.plot({
y: {
label: "↑ Temperature anomaly (°C)",
tickFormat: "+f",
@@ -19,4 +20,8 @@ export default async function() {
Plot.brush(data, {x: "Date", y: "Anomaly"})
]
});
+ const output = html`