From 704a11ebaeaf001b64c2c0b9468a364eb2bb361d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 14 Feb 2026 15:53:47 +0100 Subject: [PATCH 1/4] tip+brush combination --- src/interactions/brush.js | 2 + src/interactions/pointer.js | 4 +- test/output/brushDotTip.svg | 416 ++++++++++++++++++++++++++++++++++++ test/plots/brush.ts | 16 ++ 4 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 test/output/brushDotTip.svg diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 7069c22c86..db858ea7be 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -32,6 +32,7 @@ export class Brush extends Mark { if (type === "start" && !clearing) { target = event.sourceEvent?.currentTarget ?? this; currentNode = _brushNodes.indexOf(target); + if (event.sourceEvent) context.ownerSVGElement.classList.add("plot-brushing"); if (!clearing) { clearing = true; selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); @@ -46,6 +47,7 @@ export class Brush extends Mark { if (selection === null) { if (type === "end") { + context.ownerSVGElement.classList.remove("plot-brushing"); for (let i = 0; i < _brushNodes.length; ++i) { inactive.update(true, i); ctx.update(false, i); diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index f0c0f765b0..55d89957a4 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -140,7 +140,8 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // squashed, selecting primarily on the dominant dimension. Across facets, // use unsquashed distance to determine the winner. function pointermove(event) { - if (state.sticky || (event.pointerType === "mouse" && event.buttons === 1)) return; // dragging + if (state.sticky) return; + if (event.pointerType === "mouse" && event.buttons === 1) return void update(null); // hide tip during drag let [xp, yp] = pointof(event); (xp -= tx), (yp -= ty); // correct for facets and band scales const kpx = xp < dimensions.marginLeft || xp > dimensions.width - dimensions.marginRight ? 1 : kx; @@ -166,6 +167,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op if (i == null) return; // not pointing if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers + else if (svg.classList.contains("plot-brushing")) return void update(null); // cancel tip on brush start else (state.sticky = true), render(i); event.stopImmediatePropagation(); // suppress other pointers } diff --git a/test/output/brushDotTip.svg b/test/output/brushDotTip.svg new file mode 100644 index 0000000000..abfb11e928 --- /dev/null +++ b/test/output/brushDotTip.svg @@ -0,0 +1,416 @@ + + + + + 14 + 15 + 16 + 17 + 18 + 19 + 20 + 21 + + + ↑ culmen_depth_mm + + + + 35 + 40 + 45 + 50 + 55 + + + culmen_length_mm → + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/plots/brush.ts b/test/plots/brush.ts index 2419f4b2ed..2d8a5efb65 100644 --- a/test/plots/brush.ts +++ b/test/plots/brush.ts @@ -290,3 +290,19 @@ export async function brushSimple() { plot.oninput = oninput; return html`
${plot}${textarea}
`; } + +export async function brushDotTip() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + const brush = new Plot.Brush(); + const xy = {x: "culmen_length_mm" as const, y: "culmen_depth_mm" as const}; + const plot = Plot.plot({ + marks: [ + brush, + Plot.dot(penguins, brush.inactive({...xy, fill: "species", r: 2})), + Plot.dot(penguins, brush.context({...xy, fill: "#ccc", r: 2})), + Plot.dot(penguins, brush.focus({...xy, fill: "species", r: 3, tip: true})) + ] + }); + brush.move({x1: 36, x2: 48, y1: 15, y2: 20}); + return plot; +} From a49628f504cf1622f829dac9b9913a79fad4d782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Sat, 14 Feb 2026 15:56:24 +0100 Subject: [PATCH 2/4] no-tip Rationale: tip is more fundamental than brush, and other interactions (such as lasso) will want to do the same --- src/interactions/brush.js | 4 ++-- src/interactions/pointer.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index db858ea7be..7ca95d62e2 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -32,7 +32,7 @@ export class Brush extends Mark { if (type === "start" && !clearing) { target = event.sourceEvent?.currentTarget ?? this; currentNode = _brushNodes.indexOf(target); - if (event.sourceEvent) context.ownerSVGElement.classList.add("plot-brushing"); + if (event.sourceEvent) context.ownerSVGElement.classList.add("no-tip"); if (!clearing) { clearing = true; selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); @@ -47,7 +47,7 @@ export class Brush extends Mark { if (selection === null) { if (type === "end") { - context.ownerSVGElement.classList.remove("plot-brushing"); + context.ownerSVGElement.classList.remove("no-tip"); for (let i = 0; i < _brushNodes.length; ++i) { inactive.update(true, i); ctx.update(false, i); diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 55d89957a4..5d553fbb3e 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -167,7 +167,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op if (i == null) return; // not pointing if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers - else if (svg.classList.contains("plot-brushing")) return void update(null); // cancel tip on brush start + else if (svg.classList.contains("no-tip")) return void update(null); // cancel tip on brush start else (state.sticky = true), render(i); event.stopImmediatePropagation(); // suppress other pointers } From 4e670f8f5f22878673dcd5627e1c042a23e114ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 25 Feb 2026 15:31:48 +0100 Subject: [PATCH 3/4] fix review issue ref/ https://github.com/observablehq/plot/pull/2361#pullrequestreview-3849841810 --- src/interactions/brush.js | 2 +- src/interactions/pointer.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index cfaa17f8b0..64a5c480aa 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -56,7 +56,6 @@ export class Brush extends Mark { if (type === "start" && !snapping) { target = event.sourceEvent?.currentTarget ?? this; currentNode = _brushNodes.indexOf(target); - if (event.sourceEvent) context.ownerSVGElement.classList.add("no-tip"); if (!sync) { syncing = true; selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); @@ -106,6 +105,7 @@ export class Brush extends Mark { context.dispatchValue(value); } } else { + if (event.sourceEvent) context.ownerSVGElement.classList.add("no-tip"); const [[px1, py1], [px2, py2]] = dim === "xy" ? selection : dim === "x" ? [[selection[0]], [selection[1]]] : [[, selection[0]], [, selection[1]]]; // prettier-ignore diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 5d553fbb3e..79c1a36a5a 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -140,6 +140,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // squashed, selecting primarily on the dominant dimension. Across facets, // use unsquashed distance to determine the winner. function pointermove(event) { + if (svg.classList.contains("no-tip")) { if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); return; } // prettier-ignore if (state.sticky) return; if (event.pointerType === "mouse" && event.buttons === 1) return void update(null); // hide tip during drag let [xp, yp] = pointof(event); From fbb66e0b47d534137d96e122ab2442908d3573db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 27 Feb 2026 18:13:49 +0100 Subject: [PATCH 4/4] replace .no-tip with a mutable context.interaction = {} it needs to be an object since context gets spread in src/interactions/pointer.js:25 --- src/interactions/brush.js | 4 ++-- src/interactions/pointer.js | 4 ++-- src/plot.js | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/interactions/brush.js b/src/interactions/brush.js index 64a5c480aa..2c8d118d2e 100644 --- a/src/interactions/brush.js +++ b/src/interactions/brush.js @@ -70,7 +70,7 @@ export class Brush extends Mark { if (selection === null) { if (type === "end") { - context.ownerSVGElement.classList.remove("no-tip"); + context.interaction.brushing = false; if (sync) { syncing = true; selectAll(_brushNodes.filter((_, i) => i !== currentNode)).call(_brush.move, null); @@ -105,7 +105,7 @@ export class Brush extends Mark { context.dispatchValue(value); } } else { - if (event.sourceEvent) context.ownerSVGElement.classList.add("no-tip"); + if (event.sourceEvent) context.interaction.brushing = true; const [[px1, py1], [px2, py2]] = dim === "xy" ? selection : dim === "x" ? [[selection[0]], [selection[1]]] : [[, selection[0]], [, selection[1]]]; // prettier-ignore diff --git a/src/interactions/pointer.js b/src/interactions/pointer.js index 79c1a36a5a..a198bebc5e 100644 --- a/src/interactions/pointer.js +++ b/src/interactions/pointer.js @@ -140,7 +140,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op // squashed, selecting primarily on the dominant dimension. Across facets, // use unsquashed distance to determine the winner. function pointermove(event) { - if (svg.classList.contains("no-tip")) { if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); return; } // prettier-ignore + if (context.interaction?.brushing) { if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); return; } // prettier-ignore if (state.sticky) return; if (event.pointerType === "mouse" && event.buttons === 1) return void update(null); // hide tip during drag let [xp, yp] = pointof(event); @@ -168,7 +168,7 @@ function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, render, ...op if (i == null) return; // not pointing if (state.sticky && state.roots.some((r) => r?.contains(event.target))) return; // stay sticky if (state.sticky) (state.sticky = false), state.renders.forEach((r) => r(null)); // clear all pointers - else if (svg.classList.contains("no-tip")) return void update(null); // cancel tip on brush start + else if (context.interaction?.brushing) return void update(null); // cancel tip on brush start else (state.sticky = true), render(i); event.stopImmediatePropagation(); // suppress other pointers } diff --git a/src/plot.js b/src/plot.js index 84b6f14ea8..db52f873d9 100644 --- a/src/plot.js +++ b/src/plot.js @@ -157,6 +157,7 @@ export function plot(options = {}) { let figure = svg; // replaced with the figure element, if any context.ownerSVGElement = svg; context.className = className; + context.interaction = {}; context.projection = createProjection(options, subdimensions); // A path generator for marks that want to draw GeoJSON.