Skip to content

tip mark + pointer interaction #1527

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 56 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
c4723ff
tip mark
mbostock May 6, 2023
c4e627c
render transform!
mbostock May 7, 2023
5a50933
pointer interaction
mbostock May 7, 2023
56a23ef
port mbostock/tooltip fixes
mbostock May 7, 2023
568d218
improved tip & pointer
mbostock May 7, 2023
9854e0d
simplify
mbostock May 7, 2023
98be543
better facets; stable anchor
mbostock May 7, 2023
778b38b
px, py; crosshairs
mbostock May 7, 2023
a054cf4
crosshairs composite mark
mbostock May 7, 2023
fbdc96c
crosshair singular
mbostock May 7, 2023
85771cf
transpose facets and marks
mbostock May 8, 2023
532a605
prefer top-left
mbostock May 8, 2023
e6c1ab3
only pass index.f[xyi] if faceted
mbostock May 8, 2023
9a69230
[xy][12]; don’t apply stroke as text fill
mbostock May 8, 2023
5645501
tip + hexbin test
mbostock May 8, 2023
383fcb9
optimize faceting by swapping transforms
mbostock May 8, 2023
992ad54
renderTransform instead of _render
mbostock May 8, 2023
57bd084
only one pointer across facets
mbostock May 8, 2023
72a0d40
suppress other facets when sticky
mbostock May 8, 2023
da1df91
prevent duplicate ARIA when faceting
mbostock May 8, 2023
8174cfa
isolate state per-pointer
mbostock May 8, 2023
e0ac0e0
fix crash with one-dimensional tip
mbostock May 8, 2023
c01a775
if px, default x to null; same for py
mbostock May 8, 2023
9282918
tidier crosshair options
mbostock May 8, 2023
0ede492
use channel label if available
mbostock May 8, 2023
42a6d78
only separating space if named
mbostock May 8, 2023
5759945
crosshair initializer fixes
mbostock May 9, 2023
50a136a
tidier crosshair options
mbostock May 9, 2023
0d15771
remove to-do
mbostock May 9, 2023
8ec7a31
tip + dodge test
mbostock May 9, 2023
8b6f84e
cleaner facet translate
mbostock May 9, 2023
c385d6b
crosshair text using channel alias
mbostock May 9, 2023
736b45d
preTtier
mbostock May 9, 2023
99d4554
fix transform for [xy][12]
mbostock May 9, 2023
0521e70
p[xy] precedence
mbostock May 9, 2023
f6e68dd
pointer comments
mbostock May 9, 2023
98ad85b
tip textAnchor
mbostock May 9, 2023
d24bb0a
more tip options
mbostock May 9, 2023
6512367
more tip options, comments
mbostock May 9, 2023
f40792f
bandwidth offset
mbostock May 9, 2023
45d1d43
fix for multi-facet, multi-pointer
mbostock May 9, 2023
00be814
fix dimensions
mbostock May 10, 2023
c8417e3
tip side anchors
mbostock May 10, 2023
5b52428
tipped helper
mbostock May 10, 2023
c394c6e
raster nearest
mbostock May 10, 2023
3365727
color swatch; fix f[xy]; no tip aesthetic channels
mbostock May 10, 2023
4570bb9
multi-line, summary ariaLabel
mbostock May 10, 2023
36aa6b1
tidier formatting
mbostock May 10, 2023
383cb40
tidier crosshair
mbostock May 11, 2023
c0ec5c5
project p[xy], too
mbostock May 11, 2023
5f9c77e
centroid test
mbostock May 11, 2023
74e8423
geoCentroid test
mbostock May 11, 2023
0003e4f
shorthand extra channels
mbostock May 11, 2023
727c45b
no pointer-specific state
mbostock May 11, 2023
3921672
revert Mark interface change
mbostock May 11, 2023
7faaee3
remove dead code
mbostock May 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {registry} from "./scales/index.js";
import {isSymbol, maybeSymbol} from "./symbol.js";
import {maybeReduce} from "./transforms/group.js";

// TODO Type coercion?
export function createChannel(data, {scale, type, value, filter, hint}, name) {
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
return inferChannelScale(name, {
scale,
type,
Expand Down Expand Up @@ -160,3 +160,10 @@ function ascendingGroup([ak, av], [bk, bv]) {
function descendingGroup([ak, av], [bk, bv]) {
return descendingDefined(av, bv) || ascendingDefined(ak, bk);
}

export function getSource(channels, key) {
let channel = channels[key];
if (!channel) return;
while (channel.source) channel = channel.source;
return channel.source === null ? null : channel;
}
3 changes: 3 additions & 0 deletions src/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export interface Context {
*/
document: Document;

/** The current owner SVG element. */
ownerSVGElement: SVGSVGElement;

/** The Plot’s (typically generated) class name, for custom styles. */
className: string;

Expand Down
5 changes: 2 additions & 3 deletions src/context.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {creator, select} from "d3";
import {createProjection} from "./projection.js";

export function createContext(options = {}, dimensions, className) {
export function createContext(options = {}) {
const {document = typeof window !== "undefined" ? window.document : undefined} = options;
return {document, className, projection: createProjection(options, dimensions)};
return {document};
}

export function create(name, {document}) {
Expand Down
2 changes: 1 addition & 1 deletion src/facet.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function facetGroups(data, {fx, fy}) {
);
}

export function facetTranslate(fx, fy, {marginTop, marginLeft}) {
export function facetTranslator(fx, fy, {marginTop, marginLeft}) {
return fx && fy
? ({x, y}) => `translate(${fx(x) - marginLeft},${fy(y) - marginTop})`
: fx
Expand Down
3 changes: 3 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./curve.js";
export * from "./dimensions.js";
export * from "./format.js";
export * from "./inset.js";
export * from "./interactions/pointer.js";
export * from "./interval.js";
export * from "./legends.js";
export * from "./mark.js";
Expand All @@ -16,6 +17,7 @@ export * from "./marks/bar.js";
export * from "./marks/box.js";
export * from "./marks/cell.js";
export * from "./marks/contour.js";
export * from "./marks/crosshair.js";
export * from "./marks/delaunay.js";
export * from "./marks/density.js";
export * from "./marks/dot.js";
Expand All @@ -31,6 +33,7 @@ export * from "./marks/rect.js";
export * from "./marks/rule.js";
export * from "./marks/text.js";
export * from "./marks/tick.js";
export * from "./marks/tip.js";
export * from "./marks/tree.js";
export * from "./marks/vector.js";
export * from "./options.js";
Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {BarX, BarY, barX, barY} from "./marks/bar.js";
export {boxX, boxY} from "./marks/box.js";
export {Cell, cell, cellX, cellY} from "./marks/cell.js";
export {Contour, contour} from "./marks/contour.js";
export {crosshair, crosshairX, crosshairY} from "./marks/crosshair.js";
export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/delaunay.js";
export {Density, density} from "./marks/density.js";
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
Expand All @@ -24,6 +25,7 @@ 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";
export {TickX, TickY, tickX, tickY} from "./marks/tick.js";
export {Tip, tip} from "./marks/tip.js";
export {tree, cluster} from "./marks/tree.js";
export {Vector, vector, vectorX, vectorY, spike} from "./marks/vector.js";
export {valueof, column, identity, indexOf} from "./options.js";
Expand All @@ -39,6 +41,7 @@ export {window, windowX, windowY} from "./transforms/window.js";
export {select, selectFirst, selectLast, selectMaxX, selectMaxY, selectMinX, selectMinY} from "./transforms/select.js";
export {stackX, stackX1, stackX2, stackY, stackY1, stackY2} from "./transforms/stack.js";
export {treeNode, treeLink} from "./transforms/tree.js";
export {pointer, pointerX, pointerY} from "./interactions/pointer.js";
export {formatIsoDate, formatWeekday, formatMonth} from "./format.js";
export {scale} from "./scales.js";
export {legend} from "./legends.js";
16 changes: 16 additions & 0 deletions src/interactions/pointer.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type {Rendered} from "../transforms/basic.js";

/** TODO */
export interface PointerOptions {
/** TODO */
maxRadius?: number;
}

/** TODO */
export function pointer<T>(options: T & PointerOptions): Rendered<T>;

/** TODO */
export function pointerX<T>(options: T & PointerOptions): Rendered<T>;

/** TODO */
export function pointerY<T>(options: T & PointerOptions): Rendered<T>;
175 changes: 175 additions & 0 deletions src/interactions/pointer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import {pointer as pointof} from "d3";
import {applyFrameAnchor} from "../style.js";

const states = new WeakMap();

function pointerK(kx, ky, {x, y, px, py, maxRadius = 40, channels, ...options} = {}) {
maxRadius = +maxRadius;
// When px or py is used, register an extra channel that the pointer
// interaction can use to control which point is focused; this allows pointing
// to function independently of where the downstream mark (e.g., a tip) is
// displayed. Also default x or y to null to disable maybeTuple etc.
if (px != null) (x ??= null), (channels = {...channels, px: {value: px, scale: "x"}});
if (py != null) (y ??= null), (channels = {...channels, py: {value: py, scale: "y"}});
return {
x,
y,
channels,
...options,
render(index, scales, values, dimensions, context) {
const mark = this;
const svg = context.ownerSVGElement;

// Isolate state per-pointer, per-plot; if the pointer is reused by
// multiple marks, they will share the same state (e.g., sticky modality).
let state = states.get(svg);
if (!state) states.set(svg, (state = {sticky: false, roots: [], renders: []}));

// This serves as a unique identifier of the rendered mark per-plot; it is
// used to record the currently-rendered elements (state.roots) so that we
// can tell when a rendered element is clicked on.
let renderIndex = state.renders.push(render) - 1;

// For faceting, we want to compute the local coordinates of each point,
// which means subtracting out the facet translation, if any. (It’s
// tempting to do this using the local coordinates in SVG, but that’s
// complicated by mark-specific transforms such as dx and dy.) Also, since
// band scales return the upper bound of the band, we have to offset by
// half the bandwidth.
const {x, y, fx, fy} = scales;
let tx = fx ? fx(index.fx) - dimensions.marginLeft : 0;
let ty = fy ? fy(index.fy) - dimensions.marginTop : 0;
if (x?.bandwidth) tx += x.bandwidth() / 2;
if (y?.bandwidth) ty += y.bandwidth() / 2;

// For faceting, we also need to record the closest point per facet per
// mark (!), since each facet has its own pointer event listeners; we only
// want the closest point across facets to be visible.
const faceted = index.fi != null;
let facetState;
if (faceted) {
let facetStates = state.facetStates;
if (!facetStates) state.facetStates = facetStates = new Map();
facetState = facetStates.get(mark);
if (!facetState) facetStates.set(mark, (facetState = new Map()));
}

// The order of precedence when determining the point position is: px &
// py; the middle of x1 & y1 and x2 & y2; or lastly x & y. If any
// dimension is unspecified, we fallback to the frame anchor.
const {x: X, y: Y, x1: X1, y1: Y1, x2: X2, y2: Y2, px: PX, py: PY} = values;
const [cx, cy] = applyFrameAnchor(this, dimensions);
const px = PX ? (i) => PX[i] : X2 ? (i) => (X1[i] + X2[i]) / 2 : X ? (i) => X[i] : () => cx;
const py = PY ? (i) => PY[i] : Y2 ? (i) => (Y1[i] + Y2[i]) / 2 : Y ? (i) => Y[i] : () => cy;

let i; // currently focused index
let g; // currently rendered mark
let f; // current animation frame

// When faceting, if more than one pointer would be visible, only show
// this one if it is the closest. We defer rendering using an animation
// frame to allow all pointer events to be received before deciding which
// mark to render; although when hiding, we render immediately.
function update(ii, ri) {
if (faceted) {
if (f) f = cancelAnimationFrame(f);
if (ii == null) facetState.delete(index.fi);
else {
facetState.set(index.fi, ri);
f = requestAnimationFrame(() => {
f = null;
for (const r of facetState.values()) {
if (r < ri) {
ii = null;
break;
}
}
render(ii);
});
return;
}
}
render(ii);
}

function render(ii) {
if (i === ii) return; // the tooltip hasn’t moved
i = ii;
const I = i == null ? [] : [i];
if (faceted) (I.fx = index.fx), (I.fy = index.fy), (I.fi = index.fi);
const r = mark.render(I, scales, values, dimensions, context);
if (g) {
// When faceting, preserve swapped mark and facet transforms; also
// remove ARIA attributes since these are promoted to the parent. This
// is perhaps brittle in that it depends on how Plot renders facets,
// but it produces a cleaner and more accessible SVG structure.
if (faceted) {
const p = g.parentNode;
const ft = g.getAttribute("transform");
const mt = r.getAttribute("transform");
ft ? r.setAttribute("transform", ft) : r.removeAttribute("transform");
mt ? p.setAttribute("transform", mt) : p.removeAttribute("transform");
r.removeAttribute("aria-label");
r.removeAttribute("aria-description");
r.removeAttribute("aria-hidden");
}
g.replaceWith(r);
}
state.roots[renderIndex] = r;
return (g = r);
}

function pointermove(event) {
if (state.sticky || (event.pointerType === "mouse" && event.buttons === 1)) return; // dragging
let [xp, yp] = pointof(event);
(xp -= tx), (yp -= ty); // correct for facets and band scales
let ii = null;
let ri = maxRadius * maxRadius;
for (const j of index) {
const dx = kx * (px(j) - xp);
const dy = ky * (py(j) - yp);
const rj = dx * dx + dy * dy;
if (rj <= ri) (ii = j), (ri = rj);
}
update(ii, ri);
}

function pointerdown(event) {
if (event.pointerType !== "mouse") return;
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 state.sticky = true;
event.stopImmediatePropagation(); // suppress other pointers
}

function pointerleave(event) {
if (event.pointerType !== "mouse") return;
if (!state.sticky) update(null);
}

// We listen to the svg element; listening to the window instead would let
// us receive pointer events from farther away, but would also make it
// hard to know when to remove the listeners. (Using a mutation observer
// to watch the entire document is likely too expensive.)
svg.addEventListener("pointerenter", pointermove);
svg.addEventListener("pointermove", pointermove);
svg.addEventListener("pointerdown", pointerdown);
svg.addEventListener("pointerleave", pointerleave);

return render(null);
}
};
}

export function pointer(options) {
return pointerK(1, 1, options);
}

export function pointerX(options) {
return pointerK(1, 0.01, options);
}

export function pointerY(options) {
return pointerK(0.01, 1, options);
}
7 changes: 5 additions & 2 deletions src/mark.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {ChannelDomainSort, Channels, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
import type {Context} from "./context.js";
import type {Dimensions} from "./dimensions.js";
import type {plot} from "./plot.js";
Expand Down Expand Up @@ -126,6 +126,9 @@ export interface MarkOptions {
/** A custom mark initializer. */
initializer?: InitializerFunction;

/** A custom render transform. */
render?: RenderFunction;

/**
* The horizontal facet position channel, for mark-level faceting, bound to
* the *fx* scale.
Expand Down Expand Up @@ -445,7 +448,7 @@ export interface MarkOptions {
* An object defining additional custom channels. This meta option may be used
* by an **initializer** to declare extra channels.
*/
channels?: Channels;
channels?: Record<string, Channel | ChannelValue>;
}

/** The abstract base class for Mark implementations. */
Expand Down
Loading