Skip to content

Commit 9be3c13

Browse files
authored
tip option (#1537)
* tip option * remove dodge tip default * sort import * withTip helper
1 parent 50d57c4 commit 9be3c13

File tree

7 files changed

+91
-63
lines changed

7 files changed

+91
-63
lines changed

src/mark.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ export interface MarkOptions {
271271
*/
272272
title?: ChannelValue;
273273

274+
/** Whether to generate a tooltip for this mark. */
275+
tip?: boolean | "x" | "y" | "xy";
276+
274277
/**
275278
* How to clip the mark; one of:
276279
*

src/mark.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {channelDomain, createChannels, valueObject} from "./channel.js";
22
import {defined} from "./defined.js";
33
import {maybeFacetAnchor} from "./facet.js";
4-
import {maybeValue} from "./options.js";
5-
import {arrayify, isDomainSort, isOptions, keyword, maybeNamed, range, singleton} from "./options.js";
4+
import {maybeKeyword, maybeNamed, maybeValue} from "./options.js";
5+
import {arrayify, isDomainSort, isOptions, keyword, range, singleton} from "./options.js";
66
import {project} from "./projection.js";
77
import {maybeClip, styles} from "./style.js";
88
import {basic, initializer} from "./transforms/basic.js";
@@ -24,6 +24,7 @@ export class Mark {
2424
marginLeft = margin,
2525
clip,
2626
channels: extraChannels,
27+
tip,
2728
render
2829
} = options;
2930
this.data = data;
@@ -69,6 +70,7 @@ export class Mark {
6970
this.marginBottom = +marginBottom;
7071
this.marginLeft = +marginLeft;
7172
this.clip = maybeClip(clip);
73+
this.tip = maybeTip(tip);
7274
// Super-faceting currently disallow position channels; in the future, we
7375
// could allow position to be specified in fx and fy in addition to (or
7476
// instead of) x and y.
@@ -143,3 +145,11 @@ function maybeChannels(channels) {
143145
})
144146
);
145147
}
148+
149+
function maybeTip(tip) {
150+
return tip === true ? "xy" : maybeKeyword(tip, "tip", ["x", "y", "xy"]);
151+
}
152+
153+
export function withTip(options, tip) {
154+
return options?.tip === true ? {...options, tip} : options;
155+
}

src/marks/rule.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {create} from "../context.js";
2-
import {Mark} from "../mark.js";
2+
import {Mark, withTip} from "../mark.js";
33
import {identity, number} from "../options.js";
44
import {isCollapsed} from "../scales.js";
5-
import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js";
5+
import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js";
66
import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js";
77

88
const defaults = {
@@ -21,7 +21,7 @@ export class RuleX extends Mark {
2121
y1: {value: y1, scale: "y", optional: true},
2222
y2: {value: y2, scale: "y", optional: true}
2323
},
24-
options,
24+
withTip(options, "x"),
2525
defaults
2626
);
2727
this.insetTop = number(insetTop);
@@ -69,7 +69,7 @@ export class RuleY extends Mark {
6969
x1: {value: x1, scale: "x", optional: true},
7070
x2: {value: x2, scale: "x", optional: true}
7171
},
72-
options,
72+
withTip(options, "y"),
7373
defaults
7474
);
7575
this.insetRight = number(insetRight);

src/plot.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@ import {createChannel, inferChannelScale} from "./channel.js";
33
import {createContext} from "./context.js";
44
import {createDimensions} from "./dimensions.js";
55
import {createFacets, recreateFacets, facetExclude, facetGroups, facetTranslator, facetFilter} from "./facet.js";
6+
import {pointer, pointerX, pointerY} from "./interactions/pointer.js";
67
import {createLegends, exposeLegends} from "./legends.js";
78
import {Mark} from "./mark.js";
89
import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js";
910
import {frame} from "./marks/frame.js";
11+
import {tip} from "./marks/tip.js";
1012
import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js";
1113
import {createProjection} from "./projection.js";
1214
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
1315
import {innerDimensions, outerDimensions} from "./scales.js";
1416
import {position, registry as scaleRegistry} from "./scales/index.js";
1517
import {applyInlineStyles, maybeClassName} from "./style.js";
18+
import {initializer} from "./transforms/basic.js";
1619
import {consumeWarnings, warn} from "./warnings.js";
1720

1821
export function plot(options = {}) {
@@ -24,6 +27,9 @@ export function plot(options = {}) {
2427
// Flatten any nested marks.
2528
const marks = options.marks === undefined ? [] : flatMarks(options.marks);
2629

30+
// Add implicit tips.
31+
marks.push(...inferTips(marks));
32+
2733
// Compute the top-level facet state. This has roughly the same structure as
2834
// mark-specific facet state, except there isn’t a facetsIndex, and there’s a
2935
// data and dataLength so we can warn the user if a different data of the same
@@ -468,6 +474,24 @@ function maybeMarkFacet(mark, topFacetState, options) {
468474
}
469475
}
470476

477+
function derive(mark, options = {}) {
478+
return initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, context) => {
479+
return context.getMarkState(mark);
480+
});
481+
}
482+
483+
function inferTips(marks) {
484+
const tips = [];
485+
for (const mark of marks) {
486+
const t = mark.tip;
487+
if (t) {
488+
const p = t === "x" ? pointerX : t === "y" ? pointerY : pointer;
489+
tips.push(tip(mark.data, p(derive(mark)))); // TODO tip options?
490+
}
491+
}
492+
return tips;
493+
}
494+
471495
function inferAxes(marks, channelsByScale, options) {
472496
let {
473497
projection,

src/transforms/bin.js

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,27 @@ import {
44
thresholdFreedmanDiaconis,
55
thresholdScott,
66
thresholdSturges,
7-
ticks,
87
tickIncrement,
8+
ticks,
99
utcTickInterval
1010
} from "d3";
11+
import {withTip} from "../mark.js";
1112
import {
12-
valueof,
13-
identity,
1413
coerceDate,
1514
coerceNumbers,
15+
identity,
16+
isIterable,
17+
isTemporal,
18+
labelof,
19+
map,
20+
maybeApplyInterval,
21+
maybeColorChannel,
1622
maybeColumn,
1723
maybeRangeInterval,
1824
maybeTuple,
19-
maybeColorChannel,
2025
maybeValue,
2126
mid,
22-
labelof,
23-
isTemporal,
24-
isIterable,
25-
map
27+
valueof
2628
} from "../options.js";
2729
import {maybeUtcInterval} from "../time.js";
2830
import {basic} from "./basic.js";
@@ -40,7 +42,6 @@ import {
4042
reduceIdentity
4143
} from "./group.js";
4244
import {maybeInsetX, maybeInsetY} from "./inset.js";
43-
import {maybeApplyInterval} from "../options.js";
4445

4546
export function binX(outputs = {y: "count"}, options = {}) {
4647
// Group on {z, fill, stroke}, then optionally on y, then bin x.
@@ -69,12 +70,12 @@ function maybeDenseInterval(bin, k, options = {}) {
6970
: bin({[k]: options?.reduce === undefined ? reduceFirst : options.reduce, filter: null}, options);
7071
}
7172

72-
export function maybeDenseIntervalX(options) {
73-
return maybeDenseInterval(binX, "y", options);
73+
export function maybeDenseIntervalX(options = {}) {
74+
return maybeDenseInterval(binX, "y", withTip(options, "x"));
7475
}
7576

76-
export function maybeDenseIntervalY(options) {
77-
return maybeDenseInterval(binY, "x", options);
77+
export function maybeDenseIntervalY(options = {}) {
78+
return maybeDenseInterval(binY, "x", withTip(options, "y"));
7879
}
7980

8081
function binn(

src/transforms/stack.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3";
1+
import {InternMap, cumsum, greatest, group, groupSort, max, min, rollup, sum} from "d3";
22
import {ascendingDefined} from "../defined.js";
3-
import {field, column, maybeColumn, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js";
3+
import {withTip} from "../mark.js";
4+
import {maybeApplyInterval, maybeColumn, maybeZ, maybeZero} from "../options.js";
5+
import {column, field, mid, one, range, valueof} from "../options.js";
46
import {basic} from "./basic.js";
5-
import {maybeApplyInterval} from "../options.js";
67

78
export function stackX(stackOptions = {}, options = {}) {
89
if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions);
@@ -47,12 +48,14 @@ export function stackY2(stackOptions = {}, options = {}) {
4748
}
4849

4950
export function maybeStackX({x, x1, x2, ...options} = {}) {
51+
options = withTip(options, "y");
5052
if (x1 === undefined && x2 === undefined) return stackX({x, ...options});
5153
[x1, x2] = maybeZero(x, x1, x2);
5254
return {...options, x1, x2};
5355
}
5456

5557
export function maybeStackY({y, y1, y2, ...options} = {}) {
58+
options = withTip(options, "x");
5659
if (y1 === undefined && y2 === undefined) return stackY({y, ...options});
5760
[y1, y2] = maybeZero(y, y1, y2);
5861
return {...options, y1, y2};

test/plots/tip.ts

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,22 @@ import * as Plot from "@observablehq/plot";
22
import * as d3 from "d3";
33
import {feature, mesh} from "topojson-client";
44

5-
function tipped(mark, options = {}, pointer = Plot.pointer) {
6-
return Plot.marks(mark, Plot.tip(mark.data, pointer(derive(mark, options))));
7-
}
8-
9-
function tippedX(mark, options = {}) {
10-
return tipped(mark, options, Plot.pointerX);
11-
}
12-
13-
function tippedY(mark, options = {}) {
14-
return tipped(mark, options, Plot.pointerY);
15-
}
16-
17-
function derive(mark, options = {}) {
18-
return Plot.initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, context) => {
19-
return (context as any).getMarkState(mark);
20-
});
21-
}
22-
235
export async function tipBar() {
246
const olympians = await d3.csv<any>("data/athletes.csv", d3.autoType);
25-
return tippedY(Plot.barX(olympians, Plot.groupY({x: "count"}, {y: "sport", sort: {y: "x"}}))).plot({marginLeft: 100});
7+
return Plot.plot({
8+
marginLeft: 100,
9+
marks: [Plot.barX(olympians, Plot.groupY({x: "count"}, {y: "sport", sort: {y: "x"}, tip: true}))]
10+
});
2611
}
2712

2813
export async function tipBin() {
2914
const olympians = await d3.csv<any>("data/athletes.csv", d3.autoType);
30-
return tippedX(Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight"}))).plot();
15+
return Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", tip: true})).plot();
3116
}
3217

3318
export async function tipBinStack() {
3419
const olympians = await d3.csv<any>("data/athletes.csv", d3.autoType);
35-
return tippedX(Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex"}))).plot();
20+
return Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", tip: true})).plot();
3621
}
3722

3823
export async function tipCell() {
@@ -41,7 +26,7 @@ export async function tipCell() {
4126
height: 400,
4227
marginLeft: 100,
4328
color: {scheme: "blues"},
44-
marks: [tippedY(Plot.cell(olympians, Plot.group({fill: "count"}, {x: "sex", y: "sport"})))]
29+
marks: [Plot.cell(olympians, Plot.group({fill: "count"}, {x: "sex", y: "sport", tip: "y"}))]
4530
});
4631
}
4732

@@ -51,18 +36,18 @@ export async function tipCellFacet() {
5136
height: 400,
5237
marginLeft: 100,
5338
color: {scheme: "blues"},
54-
marks: [tippedY(Plot.cell(olympians, Plot.groupY({fill: "count"}, {fx: "sex", y: "sport"})))]
39+
marks: [Plot.cell(olympians, Plot.groupY({fill: "count"}, {fx: "sex", y: "sport", tip: "y"}))]
5540
});
5641
}
5742

5843
export async function tipDodge() {
5944
const penguins = await d3.csv<any>("data/penguins.csv", d3.autoType);
60-
return tipped(Plot.dot(penguins, Plot.dodgeY({x: "culmen_length_mm", r: "body_mass_g"}))).plot({height: 160});
45+
return Plot.dot(penguins, Plot.dodgeY({x: "culmen_length_mm", r: "body_mass_g", tip: true})).plot({height: 160});
6146
}
6247

6348
export async function tipDot() {
6449
const penguins = await d3.csv<any>("data/penguins.csv", d3.autoType);
65-
return tipped(Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"})).plot();
50+
return Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex", tip: true}).plot();
6651
}
6752

6853
export async function tipDotFacets() {
@@ -74,25 +59,27 @@ export async function tipDotFacets() {
7459
interval: "10 years"
7560
},
7661
marks: [
77-
tipped(
78-
Plot.dot(athletes, {
79-
x: "weight",
80-
y: "height",
81-
fx: "sex",
82-
fy: "date_of_birth",
83-
channels: {name: "name", sport: "sport"}
84-
})
85-
)
62+
Plot.dot(athletes, {
63+
x: "weight",
64+
y: "height",
65+
fx: "sex",
66+
fy: "date_of_birth",
67+
channels: {name: "name", sport: "sport"},
68+
tip: true
69+
})
8670
]
8771
});
8872
}
8973

9074
export async function tipDotFilter() {
9175
const penguins = await d3.csv<any>("data/penguins.csv", d3.autoType);
9276
const xy = {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"};
93-
const [dot1, tip1] = tipped(Plot.dot(penguins, {...xy, filter: (d) => d.sex === "MALE"}), {anchor: "left"});
94-
const [dot2, tip2] = tipped(Plot.dot(penguins, {...xy, filter: (d) => d.sex === "FEMALE"}), {anchor: "right"});
95-
return Plot.marks(dot1, dot2, tip1, tip2).plot();
77+
return Plot.plot({
78+
marks: [
79+
Plot.dot(penguins, {...xy, filter: (d) => d.sex === "MALE", tip: true}),
80+
Plot.dot(penguins, {...xy, filter: (d) => d.sex === "FEMALE", tip: true})
81+
]
82+
});
9683
}
9784

9885
export async function tipGeoCentroid() {
@@ -119,12 +106,12 @@ export async function tipGeoCentroid() {
119106

120107
export async function tipHexbin() {
121108
const olympians = await d3.csv<any>("data/athletes.csv", d3.autoType);
122-
return tipped(Plot.hexagon(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height"}))).plot();
109+
return Plot.hexagon(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height", tip: true})).plot();
123110
}
124111

125112
export async function tipLine() {
126113
const aapl = await d3.csv<any>("data/aapl.csv", d3.autoType);
127-
return tippedX(Plot.lineY(aapl, {x: "Date", y: "Close"})).plot();
114+
return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot();
128115
}
129116

130117
export async function tipRaster() {
@@ -135,11 +122,11 @@ export async function tipRaster() {
135122
height: 484,
136123
projection: {type: "reflect-y", inset: 3, domain},
137124
color: {type: "diverging"},
138-
marks: [tipped(Plot.raster(ca55, {x: "GRID_EAST", y: "GRID_NORTH", fill: "MAG_IGRF90", interpolate: "nearest"}))]
125+
marks: [Plot.raster(ca55, {x: "GRID_EAST", y: "GRID_NORTH", fill: "MAG_IGRF90", interpolate: "nearest", tip: true})]
139126
});
140127
}
141128

142129
export async function tipRule() {
143130
const penguins = await d3.csv<any>("data/penguins.csv", d3.autoType);
144-
return tippedX(Plot.ruleX(penguins, {x: "body_mass_g"})).plot();
131+
return Plot.ruleX(penguins, {x: "body_mass_g", tip: true}).plot();
145132
}

0 commit comments

Comments
 (0)