From e2d772905d8e637b589b40498a6987d3bbe07619 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 28 Jan 2022 17:27:13 -0800 Subject: [PATCH 01/13] pointer --- src/index.js | 3 +- src/marks/pointer.js | 103 + test/output/gistempAnomalyBrush.html | 1742 ++++++++++++ test/output/gistempAnomalyPointer.html | 3379 ++++++++++++++++++++++++ test/plots/gistemp-anomaly-brush.js | 7 +- test/plots/gistemp-anomaly-pointer.js | 27 + test/plots/index.js | 1 + 7 files changed, 5260 insertions(+), 2 deletions(-) create mode 100644 src/marks/pointer.js create mode 100644 test/output/gistempAnomalyBrush.html create mode 100644 test/output/gistempAnomalyPointer.html create mode 100644 test/plots/gistemp-anomaly-pointer.js diff --git a/src/index.js b/src/index.js index 3d282cd381..a22e5da92b 100644 --- a/src/index.js +++ b/src/index.js @@ -2,13 +2,14 @@ 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 {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/pointer.js b/src/marks/pointer.js new file mode 100644 index 0000000000..6b86654c42 --- /dev/null +++ b/src/marks/pointer.js @@ -0,0 +1,103 @@ +import {create, pointer as pointerof} from "d3"; +import {identity, maybeFrameAnchor, maybeTuple} from "../options.js"; +import {Mark} from "../plot.js"; +import {selection, selectionEquals} from "../selection.js"; +import {applyFrameAnchor} from "../style.js"; + +const defaults = { + ariaLabel: "pointer" +}; + +export class Pointer extends Mark { + constructor(data, {x, y, r = 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.r = +r; + this.mode = mode === "auto" ? (x == null ? "y" : y == null ? "x" : "xy") : mode; // TODO maybe mode + this.frameAnchor = maybeFrameAnchor(frameAnchor); + } + render(index, scales, {x: X, y: Y}, dimensions) { + const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions; + const {mode, r} = this; + const [cx, cy] = applyFrameAnchor(this, dimensions); + const r2 = r * r; + const g = create("svg:g"); + const C = []; + + g.append("g") + .attr("fill", "none") + .selectAll("circle") + .data(index) + .join("circle") + .attr("r", 4) + .attr("cx", X ? i => X[i] : cx) + .attr("cy", Y ? i => Y[i] : cy) + .each(function(i) { C[i] = this; }); + + g.append("rect") + .attr("fill", "none") + .attr("pointer-events", "all") + .attr("x", marginLeft) + .attr("y", marginTop) + .attr("width", width - marginRight) + .attr("height", height - marginBottom) + .on("pointerover pointermove", function(event) { + const [mx, my] = pointerof(event); + let S = index; + switch (mode) { + case "xy": { + S = S.filter(i => { + const dx = X[i] - mx, dy = Y[i] - my; + return dx * dx + dy * dy <= r2; + }); + break; + } + case "x": { + const [x0, x1] = [mx - r, mx + r]; + S = S.filter(i => x0 <= X[i] && X[i] <= x1); + break; + } + case "y": { + const [y0, y1] = [my - r, my + r]; + S = S.filter(i => y0 <= Y[i] && Y[i] <= y1); + break; + } + } + C.forEach(c => c.setAttribute("stroke", "none")); + S.forEach(i => C[i].setAttribute("stroke", "black")); + if (!selectionEquals(node[selection], S)) { + node[selection] = S; + node.dispatchEvent(new Event("input", {bubbles: true})); + } + }) + .on("pointerout", function() { + C.forEach(c => c.setAttribute("stroke", "none")); + node[selection] = null; + node.dispatchEvent(new Event("input", {bubbles: true})); + }); + + const node = g.node(); + node[selection] = null; + return node; + } +} + +export function pointer(data, {x, y, ...options} = {}) { + ([x, y] = maybeTuple(x, y)); + return new Pointer(data, {...options, x, y}); +} + +export function pointerX(data, {x = identity, ...options} = {}) { + return new Pointer(data, {...options, mode: "x", x, y: null}); +} + +export function pointerY(data, {y = identity, ...options} = {}) { + return new Pointer(data, {...options, mode: "y", x: null, y}); +} diff --git a/test/output/gistempAnomalyBrush.html b/test/output/gistempAnomalyBrush.html new file mode 100644 index 0000000000..61c6cc36e6 --- /dev/null +++ b/test/output/gistempAnomalyBrush.html @@ -0,0 +1,1742 @@ + + + + + + −0.6 + + + + −0.4 + + + + −0.2 + + + + +0.0 + + + + +0.2 + + + + +0.4 + + + + +0.6 + + + + +0.8 + + + + +1.0 + + + + +1.2 + ↑ Temperature anomaly (°C) + + + + 1880 + + + 1900 + + + 1920 + + + 1940 + + + 1960 + + + 1980 + + + 2000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1644 \ No newline at end of file diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html new file mode 100644 index 0000000000..c1094596d1 --- /dev/null +++ b/test/output/gistempAnomalyPointer.html @@ -0,0 +1,3379 @@ + + + + + + −0.6 + + + + −0.4 + + + + −0.2 + + + + +0.0 + + + + +0.2 + + + + +0.4 + + + + +0.6 + + + + +0.8 + + + + +1.0 + + + + +1.2 + ↑ Temperature anomaly (°C) + + + + 1880 + + + 1900 + + + 1920 + + + 1940 + + + 1960 + + + 1980 + + + 2000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1644 \ 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``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; } diff --git a/test/plots/gistemp-anomaly-pointer.js b/test/plots/gistemp-anomaly-pointer.js new file mode 100644 index 0000000000..cb90ed3219 --- /dev/null +++ b/test/plots/gistemp-anomaly-pointer.js @@ -0,0 +1,27 @@ +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); + const plot = Plot.plot({ + y: { + label: "↑ Temperature anomaly (°C)", + tickFormat: "+f", + grid: true + }, + color: { + type: "diverging", + reverse: true + }, + marks: [ + Plot.ruleY([0]), + Plot.dot(data, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), + Plot.pointer(data, {x: "Date", y: "Anomaly", mode: "x"}) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} diff --git a/test/plots/index.js b/test/plots/index.js index 8aac7a927e..cd2f0f1ed8 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -50,6 +50,7 @@ export {default as fruitSalesDate} from "./fruit-sales-date.js"; export {default as gistempAnomaly} from "./gistemp-anomaly.js"; export {default as gistempAnomalyBrush} from "./gistemp-anomaly-brush.js"; export {default as gistempAnomalyMoving} from "./gistemp-anomaly-moving.js"; +export {default as gistempAnomalyPointer} from "./gistemp-anomaly-pointer.js"; export {default as gistempAnomalyTransform} from "./gistemp-anomaly-transform.js"; export {default as googleTrendsRidgeline} from "./google-trends-ridgeline.js"; export {default as gridChoropleth} from "./grid-choropleth.js"; From 5440d209b47e604f2785e2457e150df046c8cf19 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sat, 29 Jan 2022 13:19:50 -0800 Subject: [PATCH 02/13] pointer top n --- src/marks/pointer.js | 66 ++++++++++++++++++++++----- test/plots/gistemp-anomaly-pointer.js | 2 +- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/marks/pointer.js b/src/marks/pointer.js index 6b86654c42..77a56e7f5b 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -1,4 +1,4 @@ -import {create, pointer as pointerof} from "d3"; +import {create, pointer as pointerof, quickselect} from "d3"; import {identity, maybeFrameAnchor, maybeTuple} from "../options.js"; import {Mark} from "../plot.js"; import {selection, selectionEquals} from "../selection.js"; @@ -9,7 +9,15 @@ const defaults = { }; export class Pointer extends Mark { - constructor(data, {x, y, r = 20, mode = "auto", frameAnchor, ...options} = {}) { + constructor(data, { + x, + y, + n = Infinity, + r = isFinite(n) ? 120 : 20, + mode = "auto", + frameAnchor, + ...options + } = {}) { super( data, [ @@ -19,13 +27,14 @@ export class Pointer extends Mark { options, defaults ); + this.n = +n; this.r = +r; this.mode = mode === "auto" ? (x == null ? "y" : y == null ? "x" : "xy") : mode; // TODO maybe mode this.frameAnchor = maybeFrameAnchor(frameAnchor); } render(index, scales, {x: X, y: Y}, dimensions) { const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions; - const {mode, r} = this; + const {mode, n, r} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); const r2 = r * r; const g = create("svg:g"); @@ -48,25 +57,58 @@ export class Pointer extends Mark { .attr("y", marginTop) .attr("width", width - marginRight) .attr("height", height - marginBottom) - .on("pointerover pointermove", function(event) { + .on("pointerover pointermove", (event) => { const [mx, my] = pointerof(event); let S = index; switch (mode) { case "xy": { - S = S.filter(i => { - const dx = X[i] - mx, dy = Y[i] - my; - return dx * dx + dy * dy <= r2; - }); + 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": { - const [x0, x1] = [mx - r, mx + r]; - S = S.filter(i => x0 <= X[i] && X[i] <= x1); + 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": { - const [y0, y1] = [my - r, my + r]; - S = S.filter(i => y0 <= Y[i] && Y[i] <= y1); + 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; } } diff --git a/test/plots/gistemp-anomaly-pointer.js b/test/plots/gistemp-anomaly-pointer.js index cb90ed3219..82480f948d 100644 --- a/test/plots/gistemp-anomaly-pointer.js +++ b/test/plots/gistemp-anomaly-pointer.js @@ -17,7 +17,7 @@ export default async function() { marks: [ Plot.ruleY([0]), Plot.dot(data, {x: "Date", y: "Anomaly", stroke: "Anomaly"}), - Plot.pointer(data, {x: "Date", y: "Anomaly", mode: "x"}) + Plot.pointer(data, {x: "Date", y: "Anomaly", n: 5}) ] }); const output = html``; From 49edba02fc42e6e53dfae1e9885edb1ac07660d5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Feb 2022 13:19:02 -0800 Subject: [PATCH 03/13] blue selection; full bleed --- src/marks/pointer.js | 13 +- test/output/gistempAnomalyBrush.html | 2 +- test/output/gistempAnomalyBrush.svg | 1742 ------------------------ test/output/gistempAnomalyPointer.html | 6 +- 4 files changed, 11 insertions(+), 1752 deletions(-) delete mode 100644 test/output/gistempAnomalyBrush.svg diff --git a/src/marks/pointer.js b/src/marks/pointer.js index 77a56e7f5b..7c82833732 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -37,9 +37,12 @@ export class Pointer extends Mark { const {mode, n, r} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); const r2 = r * r; - const g = create("svg:g"); const C = []; + const g = create("svg:g") + .style("color", "#3b5fc0") + .attr("stroke-width", 1.5); + g.append("g") .attr("fill", "none") .selectAll("circle") @@ -53,10 +56,8 @@ export class Pointer extends Mark { g.append("rect") .attr("fill", "none") .attr("pointer-events", "all") - .attr("x", marginLeft) - .attr("y", marginTop) - .attr("width", width - marginRight) - .attr("height", height - marginBottom) + .attr("width", width + marginLeft + marginRight) + .attr("height", height + marginTop + marginBottom) .on("pointerover pointermove", (event) => { const [mx, my] = pointerof(event); let S = index; @@ -113,7 +114,7 @@ export class Pointer extends Mark { } } C.forEach(c => c.setAttribute("stroke", "none")); - S.forEach(i => C[i].setAttribute("stroke", "black")); + S.forEach(i => C[i].setAttribute("stroke", "currentColor")); if (!selectionEquals(node[selection], S)) { node[selection] = S; node.dispatchEvent(new Event("input", {bubbles: true})); diff --git a/test/output/gistempAnomalyBrush.html b/test/output/gistempAnomalyBrush.html index 61c6cc36e6..9f33ad8da9 100644 --- a/test/output/gistempAnomalyBrush.html +++ b/test/output/gistempAnomalyBrush.html @@ -13,7 +13,7 @@ white-space: pre; } - + −0.6 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 @@ - - - - - - −0.6 - - - - −0.4 - - - - −0.2 - - - - +0.0 - - - - +0.2 - - - - +0.4 - - - - +0.6 - - - - +0.8 - - - - +1.0 - - - - +1.2 - ↑ Temperature anomaly (°C) - - - - 1880 - - - 1900 - - - 1920 - - - 1940 - - - 1960 - - - 1980 - - - 2000 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html index c1094596d1..c51c182eaf 100644 --- a/test/output/gistempAnomalyPointer.html +++ b/test/output/gistempAnomalyPointer.html @@ -13,7 +13,7 @@ white-space: pre; } - + −0.6 @@ -1727,7 +1727,7 @@ - + @@ -3374,6 +3374,6 @@ - + 1644 \ No newline at end of file From 46904650f775e7e58b63e84433ff454a8936b5f4 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Feb 2022 13:26:18 -0800 Subject: [PATCH 04/13] relax brushX and brushY --- src/marks/brush.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}); } From f05fa1424fc6dc110f88c1bc489d51bb80c533b5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Feb 2022 13:38:37 -0800 Subject: [PATCH 05/13] relax pointerX and pointerY --- src/marks/pointer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/marks/pointer.js b/src/marks/pointer.js index 7c82833732..a412c6eb4a 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -137,10 +137,10 @@ export function pointer(data, {x, y, ...options} = {}) { return new Pointer(data, {...options, x, y}); } -export function pointerX(data, {x = identity, ...options} = {}) { - return new Pointer(data, {...options, mode: "x", x, y: null}); +export function pointerX(data, {mode = "x", x = identity, ...options} = {}) { + return new Pointer(data, {...options, mode, x}); } -export function pointerY(data, {y = identity, ...options} = {}) { - return new Pointer(data, {...options, mode: "y", x: null, y}); +export function pointerY(data, {mode = "y", y = identity, ...options} = {}) { + return new Pointer(data, {...options, mode, y}); } From e5b1da00bb4c0e4e5a09285381bb268d89d20084 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Feb 2022 13:49:00 -0800 Subject: [PATCH 06/13] maybeMode --- src/marks/pointer.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/marks/pointer.js b/src/marks/pointer.js index a412c6eb4a..9f2b6617da 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -29,7 +29,7 @@ export class Pointer extends Mark { ); this.n = +n; this.r = +r; - this.mode = mode === "auto" ? (x == null ? "y" : y == null ? "x" : "xy") : mode; // TODO maybe mode + this.mode = maybeMode(mode, x, y); this.frameAnchor = maybeFrameAnchor(frameAnchor); } render(index, scales, {x: X, y: Y}, dimensions) { @@ -132,6 +132,17 @@ export class Pointer extends Mark { } } +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}); From 3fa8dbe655b462517ea3dfa9872fadcf9ae406c5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Feb 2022 14:22:39 -0800 Subject: [PATCH 07/13] lazy rendering of selection --- src/marks/pointer.js | 63 +- test/output/gistempAnomalyPointer.html | 1651 +----------------------- 2 files changed, 47 insertions(+), 1667 deletions(-) diff --git a/src/marks/pointer.js b/src/marks/pointer.js index 9f2b6617da..b82732eca4 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -1,7 +1,7 @@ -import {create, pointer as pointerof, quickselect} from "d3"; +import {create, namespaces, pointer as pointerof, quickselect} from "d3"; import {identity, maybeFrameAnchor, maybeTuple} from "../options.js"; import {Mark} from "../plot.js"; -import {selection, selectionEquals} from "../selection.js"; +import {selection} from "../selection.js"; import {applyFrameAnchor} from "../style.js"; const defaults = { @@ -37,29 +37,27 @@ export class Pointer extends Mark { const {mode, n, r} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); const r2 = r * r; - const C = []; + let C = []; const g = create("svg:g") - .style("color", "#3b5fc0") - .attr("stroke-width", 1.5); + .attr("fill", "none"); - g.append("g") - .attr("fill", "none") - .selectAll("circle") - .data(index) - .join("circle") - .attr("r", 4) - .attr("cx", X ? i => X[i] : cx) - .attr("cy", Y ? i => Y[i] : cy) - .each(function(i) { C[i] = this; }); + const parent = g.append("g") + .attr("stroke", "#3b5fc0") + .attr("stroke-width", 1.5) + .node(); g.append("rect") - .attr("fill", "none") .attr("pointer-events", "all") .attr("width", width + marginLeft + marginRight) .attr("height", height + marginTop + marginBottom) .on("pointerover pointermove", (event) => { const [mx, my] = pointerof(event); + + // Compute the selection index S: the subset of index that is + // logically selected. Note that while normally this should be an + // in-order subset of index, it isn’t here if the n option is + // specified because quickselect will reorder in-place! let S = index; switch (mode) { case "xy": { @@ -113,15 +111,42 @@ export class Pointer extends Mark { break; } } - C.forEach(c => c.setAttribute("stroke", "none")); - S.forEach(i => C[i].setAttribute("stroke", "currentColor")); - if (!selectionEquals(node[selection], S)) { + + // Add a circle for any newly-selected datum; remove a circle for any + // no-longer-selected datum. The order of these elements is arbitrary, + // with the most recently selected datum on top. + let C2 = []; + let changed = false; + 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; + } + C2[i] = c; + }); + C.forEach((c, i) => { + if (!C2[i]) { + c.remove(); + changed = true; + } + }); + C = C2; + + // If the selection changed, emit an input event. + if (changed) { node[selection] = S; node.dispatchEvent(new Event("input", {bubbles: true})); } }) .on("pointerout", function() { - C.forEach(c => c.setAttribute("stroke", "none")); + C.forEach(c => c.remove()); + C = []; node[selection] = null; node.dispatchEvent(new Event("input", {bubbles: true})); }); diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html index c51c182eaf..61f603420b 100644 --- a/test/output/gistempAnomalyPointer.html +++ b/test/output/gistempAnomalyPointer.html @@ -1727,1653 +1727,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + 1644 \ No newline at end of file From c3e6c581862b84ac03f0aef8c6ed67b716c5d1d5 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Tue, 1 Feb 2022 16:37:25 -0800 Subject: [PATCH 08/13] persistent pointer selection --- src/marks/pointer.js | 141 ++++++++++++++++--------- test/output/gistempAnomalyPointer.html | 2 +- 2 files changed, 93 insertions(+), 50 deletions(-) diff --git a/src/marks/pointer.js b/src/marks/pointer.js index b82732eca4..a91445a945 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -1,11 +1,14 @@ -import {create, namespaces, pointer as pointerof, quickselect} from "d3"; +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 {applyFrameAnchor} from "../style.js"; +import {applyDirectStyles, applyFrameAnchor, applyIndirectStyles} from "../style.js"; const defaults = { - ariaLabel: "pointer" + ariaLabel: "pointer", + fill: "none", + stroke: "#3b5fc0", + strokeWidth: 1.5 }; export class Pointer extends Mark { @@ -36,28 +39,91 @@ export class Pointer extends Mark { const {marginLeft, width, marginRight, marginTop, height, marginBottom} = dimensions; const {mode, n, r} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); - const r2 = r * r; - let C = []; + 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") .attr("fill", "none"); const parent = g.append("g") - .attr("stroke", "#3b5fc0") - .attr("stroke-width", 1.5) + .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("pointer-events", "all") .attr("width", width + marginLeft + marginRight) .attr("height", height + marginTop + marginBottom) - .on("pointerover pointermove", (event) => { - const [mx, my] = pointerof(event); + .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); + } - // Compute the selection index S: the subset of index that is - // logically selected. Note that while normally this should be an - // in-order subset of index, it isn’t here if the n option is - // specified because quickselect will reorder in-place! + // 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": { @@ -112,43 +178,20 @@ export class Pointer extends Mark { } } - // Add a circle for any newly-selected datum; remove a circle for any - // no-longer-selected datum. The order of these elements is arbitrary, - // with the most recently selected datum on top. - let C2 = []; - let changed = false; - 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; - } - C2[i] = c; - }); - C.forEach((c, i) => { - if (!C2[i]) { - c.remove(); - changed = true; - } - }); - C = C2; - - // If the selection changed, emit an input event. - if (changed) { - node[selection] = S; - node.dispatchEvent(new Event("input", {bubbles: true})); - } + // 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", function() { - C.forEach(c => c.remove()); - C = []; - node[selection] = null; - node.dispatchEvent(new Event("input", {bubbles: true})); + .on("pointerout", () => { + // On pointerout, if there is no persistent selection, clear the + // ephemeral selection. + if (!P) select(null); }); const node = g.node(); diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html index 61f603420b..81ef6d6043 100644 --- a/test/output/gistempAnomalyPointer.html +++ b/test/output/gistempAnomalyPointer.html @@ -1728,7 +1728,7 @@ - + 1644 \ No newline at end of file From 8f7f784619e4199c3144678094ad2d74e6f4e8ff Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Wed, 2 Feb 2022 14:31:10 -0800 Subject: [PATCH 09/13] fix pointer fill --- src/marks/pointer.js | 4 ++-- test/output/gistempAnomalyPointer.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/marks/pointer.js b/src/marks/pointer.js index a91445a945..08120f1f7c 100644 --- a/src/marks/pointer.js +++ b/src/marks/pointer.js @@ -44,8 +44,7 @@ export class Pointer extends Mark { 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") - .attr("fill", "none"); + const g = create("svg:g"); const parent = g.append("g") .call(applyIndirectStyles, this) @@ -97,6 +96,7 @@ export class Pointer extends Mark { } g.append("rect") + .attr("fill", "none") .attr("pointer-events", "all") .attr("width", width + marginLeft + marginRight) .attr("height", height + marginTop + marginBottom) diff --git a/test/output/gistempAnomalyPointer.html b/test/output/gistempAnomalyPointer.html index 81ef6d6043..f86c9ef973 100644 --- a/test/output/gistempAnomalyPointer.html +++ b/test/output/gistempAnomalyPointer.html @@ -1727,8 +1727,8 @@ - + - + 1644 \ No newline at end of file From c905213b7e0bbda33e223443d62c596184037965 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 3 Feb 2022 07:54:55 -0800 Subject: [PATCH 10/13] brushX test --- test/output/penguinCulmenBrushX.html | 2988 ++++++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/penguin-culmen-brush-x.js | 38 + 3 files changed, 3027 insertions(+) create mode 100644 test/output/penguinCulmenBrushX.html create mode 100644 test/plots/penguin-culmen-brush-x.js 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 @@ + + + + + Adelie + + + Chinstrap + + + Gentoo + species + + + + FEMALE + + + MALE + + + + sex + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + ↑ culmen_length_mm + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 344 \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index cd2f0f1ed8..4d9e79f423 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -92,6 +92,7 @@ export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; export {default as penguinCulmenBrush} from "./penguin-culmen-brush.js"; +export {default as penguinCulmenBrushX} from "./penguin-culmen-brush-x.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; diff --git a/test/plots/penguin-culmen-brush-x.js b/test/plots/penguin-culmen-brush-x.js new file mode 100644 index 0000000000..09dd023e4d --- /dev/null +++ b/test/plots/penguin-culmen-brush-x.js @@ -0,0 +1,38 @@ +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/penguins.csv", d3.autoType); + const plot = Plot.plot({ + height: 600, + grid: true, + facet: { + data, + x: "sex", + y: "species", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(data, { + facet: "exclude", + x: "culmen_depth_mm", + y: "culmen_length_mm", + r: 2, + fill: "#ddd" + }), + Plot.dot(data, { + x: "culmen_depth_mm", + y: "culmen_length_mm" + }), + Plot.brushX(data, { + x: "culmen_depth_mm" + }) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} From 84f6f92c44184091fcc3bd990b28f879374a7a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 31 Jan 2022 17:25:28 +0100 Subject: [PATCH 11/13] lasso --- src/index.js | 1 + src/marks/lasso.js | 162 ++++ test/output/penguinCulmenLasso.html | 1143 +++++++++++++++++++++++++++ test/plots/index.js | 1 + test/plots/penguin-culmen-lasso.js | 39 + 5 files changed, 1346 insertions(+) create mode 100644 src/marks/lasso.js create mode 100644 test/output/penguinCulmenLasso.html create mode 100644 test/plots/penguin-culmen-lasso.js diff --git a/src/index.js b/src/index.js index a22e5da92b..ced844c56b 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ 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"; diff --git a/src/marks/lasso.js b/src/marks/lasso.js new file mode 100644 index 0000000000..62a7a2c2cf --- /dev/null +++ b/src/marks/lasso.js @@ -0,0 +1,162 @@ +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, scales, {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("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); + const activePolygons = polygons.find(polygon => polygon.length > 2); + const S = !activePolygons ? null + : index.filter(i => polygons.some(polygon => polygon.length > 2 && 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/test/output/penguinCulmenLasso.html b/test/output/penguinCulmenLasso.html new file mode 100644 index 0000000000..a224597f15 --- /dev/null +++ b/test/output/penguinCulmenLasso.html @@ -0,0 +1,1143 @@ + + + + + 35 + + + + 40 + + + + 45 + + + + 50 + + + + 55 + + ↑ culmen_length_mm + + + + FEMALE + + + MALE + + + + sex + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + + + + + + + + 15 + + + + 20 + culmen_depth_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 344 \ No newline at end of file diff --git a/test/plots/index.js b/test/plots/index.js index cd2f0f1ed8..d4eedf796f 100644 --- a/test/plots/index.js +++ b/test/plots/index.js @@ -92,6 +92,7 @@ export {default as ordinalBar} from "./ordinal-bar.js"; export {default as penguinCulmen} from "./penguin-culmen.js"; export {default as penguinCulmenArray} from "./penguin-culmen-array.js"; export {default as penguinCulmenBrush} from "./penguin-culmen-brush.js"; +export {default as penguinCulmenLasso} from "./penguin-culmen-lasso.js"; export {default as penguinIslandUnknown} from "./penguin-island-unknown.js"; export {default as penguinMass} from "./penguin-mass.js"; export {default as penguinMassSex} from "./penguin-mass-sex.js"; diff --git a/test/plots/penguin-culmen-lasso.js b/test/plots/penguin-culmen-lasso.js new file mode 100644 index 0000000000..de85904be8 --- /dev/null +++ b/test/plots/penguin-culmen-lasso.js @@ -0,0 +1,39 @@ +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/penguins.csv", d3.autoType); + const plot = Plot.plot({ + height: 300, + grid: true, + facet: { + data, + x: "sex", + marginRight: 80 + }, + marks: [ + Plot.frame(), + Plot.dot(data, { + facet: "exclude", + x: "culmen_depth_mm", + y: "culmen_length_mm", + r: 2, + fill: "#ddd" + }), + Plot.dot(data, { + x: "culmen_depth_mm", + y: "culmen_length_mm" + }), + Plot.lasso(data, { + x: "culmen_depth_mm", + y: "culmen_length_mm", + fill: "green" + }) + ] + }); + const output = html``; + plot.oninput = () => output.value = plot.value.length; + plot.oninput(); + return html`${plot}${output}`; +} From 36d34dd81c52c300d11973659b1c754723290c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 2 Feb 2022 16:38:21 +0100 Subject: [PATCH 12/13] for band scales, lasso catches the center of the shape --- src/marks/lasso.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/marks/lasso.js b/src/marks/lasso.js index 62a7a2c2cf..118be09f33 100644 --- a/src/marks/lasso.js +++ b/src/marks/lasso.js @@ -28,7 +28,7 @@ export class Lasso extends Mark { // The lasso polygons follow the even-odd rule in css, matching the way // they are computed by polygonContains. - render(index, scales, {x: X, y: Y}, dimensions) { + 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; @@ -53,9 +53,15 @@ export class Lasso extends Mark { .data(polygons) .join("path") .attr("d", path); - const activePolygons = polygons.find(polygon => polygon.length > 2); - const S = !activePolygons ? null - : index.filter(i => polygons.some(polygon => polygon.length > 2 && polygonContains(polygon, [X[i], Y[i]]))); + 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})); From 264e64c7fab3ab8010e130a38696ea4e22a1f14d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 3 Feb 2022 15:55:40 +0100 Subject: [PATCH 13/13] spurious stroke on catchment rect --- src/marks/lasso.js | 1 + test/output/penguinCulmenLasso.html | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/marks/lasso.js b/src/marks/lasso.js index 118be09f33..7a832187a7 100644 --- a/src/marks/lasso.js +++ b/src/marks/lasso.js @@ -41,6 +41,7 @@ export class Lasso extends Mark { .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") diff --git a/test/output/penguinCulmenLasso.html b/test/output/penguinCulmenLasso.html index a224597f15..a4ca0c096e 100644 --- a/test/output/penguinCulmenLasso.html +++ b/test/output/penguinCulmenLasso.html @@ -432,7 +432,7 @@ - + @@ -784,7 +784,7 @@ - + @@ -1136,7 +1136,7 @@ - +