Skip to content

Add tips to auto #1532

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

Closed
wants to merge 44 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
44a2a24
tip mark
mbostock May 6, 2023
018a103
render transform!
mbostock May 7, 2023
31d73b8
pointer interaction
mbostock May 7, 2023
4b11d54
port mbostock/tooltip fixes
mbostock May 7, 2023
5e32892
improved tip & pointer
mbostock May 7, 2023
0565205
simplify
mbostock May 7, 2023
4a927ce
better facets; stable anchor
mbostock May 7, 2023
f5b7eff
px, py; crosshairs
mbostock May 7, 2023
21ca74c
crosshairs composite mark
mbostock May 7, 2023
11da9f0
crosshair singular
mbostock May 7, 2023
c3173c7
transpose facets and marks
mbostock May 8, 2023
6d9059f
prefer top-left
mbostock May 8, 2023
f084223
only pass index.f[xyi] if faceted
mbostock May 8, 2023
7af9658
[xy][12]; don’t apply stroke as text fill
mbostock May 8, 2023
129bd1e
tip + hexbin test
mbostock May 8, 2023
44a691c
optimize faceting by swapping transforms
mbostock May 8, 2023
165fe71
renderTransform instead of _render
mbostock May 8, 2023
7a72911
only one pointer across facets
mbostock May 8, 2023
c69916b
suppress other facets when sticky
mbostock May 8, 2023
2098f1e
prevent duplicate ARIA when faceting
mbostock May 8, 2023
0f95c93
isolate state per-pointer
mbostock May 8, 2023
1456c38
fix crash with one-dimensional tip
mbostock May 8, 2023
106d018
if px, default x to null; same for py
mbostock May 8, 2023
93eba56
tidier crosshair options
mbostock May 8, 2023
dc28cfb
use channel label if available
mbostock May 8, 2023
40f4349
only separating space if named
mbostock May 8, 2023
3056f2a
crosshair initializer fixes
mbostock May 9, 2023
82255c8
tidier crosshair options
mbostock May 9, 2023
207d045
remove to-do
mbostock May 9, 2023
b160b87
tip + dodge test
mbostock May 9, 2023
205d82a
cleaner facet translate
mbostock May 9, 2023
1ed5f69
crosshair text using channel alias
mbostock May 9, 2023
e1dbb2a
preTtier
mbostock May 9, 2023
cdf3dd6
fix transform for [xy][12]
mbostock May 9, 2023
458493f
p[xy] precedence
mbostock May 9, 2023
8679055
pointer comments
mbostock May 9, 2023
a241834
tip textAnchor
mbostock May 9, 2023
2765758
more tip options
mbostock May 9, 2023
ba940c1
more tip options, comments
mbostock May 9, 2023
f420b81
bandwidth offset
mbostock May 9, 2023
db8baa0
fix for multi-facet, multi-pointer
mbostock May 9, 2023
3c45745
most minimal sloppy adding tip/pointer to auto
tophtucker May 9, 2023
950051e
add area
tophtucker May 9, 2023
1618099
add rule
tophtucker May 9, 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
2 changes: 1 addition & 1 deletion src/channel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export type ChannelName =
* An object literal of channel definitions. This is also used to represent
* materialized channel states after mark initialization.
*/
export type Channels = {[key in ChannelName]?: Channel};
export type Channels = Record<string, Channel>;

/**
* A channel definition. This is also used to represent the materialized channel
Expand Down
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
4 changes: 3 additions & 1 deletion src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {createProjection} from "./projection.js";

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

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>;
174 changes: 174 additions & 0 deletions src/interactions/pointer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {pointer as pointof} from "d3";
import {applyFrameAnchor} from "../style.js";

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"}});
const states = new WeakMap();
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);
}
3 changes: 3 additions & 0 deletions src/mark.d.ts
Original file line number Diff line number Diff line change
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
9 changes: 7 additions & 2 deletions src/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export class Mark {
marginBottom = margin,
marginLeft = margin,
clip,
channels: extraChannels
channels: extraChannels,
render
} = options;
this.data = data;
this.sort = isDomainSort(sort) ? sort : null;
Expand Down Expand Up @@ -78,13 +79,17 @@ export class Mark {
throw new Error(`super-faceting cannot use x or y`);
}
}
if (render != null) {
if (typeof render !== "function") throw new TypeError(`invalid render transform: ${render}`);
this.renderTransform = render;
}
}
initialize(facets, facetChannels, plotOptions) {
let data = arrayify(this.data);
if (facets === undefined && data != null) facets = [range(data)];
const originalFacets = facets;
if (this.transform != null) ({facets, data} = this.transform(data, facets, plotOptions)), (data = arrayify(data));
if (facets !== undefined) facets.original = originalFacets; // needed up read facetChannels
if (facets !== undefined) facets.original = originalFacets; // needed to read facetChannels
const channels = createChannels(this.channels, data);
if (this.sort != null) channelDomain(data, facets, channels, facetChannels, this.sort); // mutates facetChannels!
return {data, facets, channels};
Expand Down
30 changes: 28 additions & 2 deletions src/marks/auto.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {frame} from "./frame.js";
import {line, lineX, lineY} from "./line.js";
import {rectX, rectY} from "./rect.js";
import {ruleX, ruleY} from "./rule.js";
import {tip} from "./tip.js";
import {pointer, pointerX, pointerY} from "../interactions/pointer.js";

export function autoSpec(data, options) {
const {x, y, fx, fy, color, size, mark} = autoImpl(data, options);
Expand Down Expand Up @@ -228,8 +230,32 @@ export function auto(data, options) {
// (particularly dots and lines) they should come before the mark.
const frames = fx != null || fy != null ? frame({strokeOpacity: 0.1}) : null;
const rules = [xZero ? ruleX([0]) : null, yZero ? ruleY([0]) : null];
const mark = markImpl(data, transformImpl ? transformImpl(transformOptions, markOptions) : markOptions);
return colorMode === "stroke" ? marks(frames, rules, mark) : marks(frames, mark, rules);
const maybeTransformedOptions = transformImpl ? transformImpl(transformOptions, markOptions) : markOptions;
const mark = markImpl(data, maybeTransformedOptions);

let point;
switch (markImpl) {
case barY:
case rectY:
case lineY:
case areaY:
case ruleX:
point = pointerX;
break;
case barX:
case rectX:
case lineX:
case areaX:
case ruleY:
point = pointerY;
break;
default:
point = pointer;
break;
}
const tooltip = tip(data, point(maybeTransformedOptions));

return colorMode === "stroke" ? marks(frames, rules, mark, tooltip) : marks(frames, mark, rules, tooltip);
}

// TODO What about sorted within series?
Expand Down
Loading