From 56dc402c3101ca9f0dc883f27bf43457e74a5c9b Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Fri, 5 May 2023 16:30:42 -0700 Subject: [PATCH] tooltip: true --- src/channel.js | 7 ++++--- src/mark.d.ts | 3 +++ src/mark.js | 10 +++++++--- src/plot.js | 34 +++++++++++++++++++++++++++++++++- src/transforms/stack.js | 16 ++++++++-------- test/plots/tooltip.ts | 4 ++-- 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/channel.js b/src/channel.js index 2f6ab38509..ff44b00ce8 100644 --- a/src/channel.js +++ b/src/channel.js @@ -5,15 +5,16 @@ import {registry} from "./scales/index.js"; import {isSymbol, maybeSymbol} from "./symbol.js"; import {maybeReduce} from "./transforms/group.js"; -export function createChannel(data, {scale, type, value, filter, hint}, name) { - if (hint === undefined && typeof value?.transform === "function") hint = value.hint; +export function createChannel(data, channel, name) { + if (channel.alias) return channel; + const {scale, type, value, filter, hint} = channel; return inferChannelScale(name, { scale, type, value: valueof(data, value), label: labelof(value), filter, - hint + hint: hint === undefined && typeof value?.transform === "function" ? value.hint : hint }); } diff --git a/src/mark.d.ts b/src/mark.d.ts index 2989400944..f60f168daa 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -438,6 +438,9 @@ export interface MarkOptions { * by an **initializer** to declare extra channels. */ channels?: Channels; + + /** TODO */ + tooltip?: boolean; } /** The abstract base class for Mark implementations. */ diff --git a/src/mark.js b/src/mark.js index d1cc8784f4..28667d7c35 100644 --- a/src/mark.js +++ b/src/mark.js @@ -14,6 +14,8 @@ export class Mark { fx, fy, sort, + tooltip, + tooltipAxis, dx = 0, dy = 0, margin = 0, @@ -25,6 +27,8 @@ export class Mark { channels: extraChannels } = options; this.data = data; + this.tooltip = !!tooltip; + this.tooltipAxis = tooltipAxis; // TODO validate this.sort = isDomainSort(sort) ? sort : null; this.initializer = initializer(options).initializer; this.transform = this.initializer ? options.transform : basic(options).transform; @@ -37,7 +41,7 @@ export class Mark { } this.facetAnchor = maybeFacetAnchor(facetAnchor); channels = maybeNamed(channels); - if (extraChannels !== undefined) channels = {...maybeNamed(extraChannels), ...channels}; + if (extraChannels !== undefined) channels = {...channels, ...maybeNamed(extraChannels)}; if (defaults !== undefined) channels = {...styles(this, options, defaults), ...channels}; this.channels = Object.fromEntries( Object.entries(channels) @@ -54,8 +58,8 @@ export class Mark { } return [name, channel]; }) - .filter(([name, {value, optional}]) => { - if (value != null) return true; + .filter(([name, {alias, value, optional}]) => { + if (value != null || alias) return true; if (optional) return false; throw new Error(`missing channel value: ${name}`); }) diff --git a/src/plot.js b/src/plot.js index a700d86e20..b956a61790 100644 --- a/src/plot.js +++ b/src/plot.js @@ -7,6 +7,7 @@ import {createLegends, exposeLegends} from "./legends.js"; import {Mark} from "./mark.js"; import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js"; import {frame} from "./marks/frame.js"; +import {tooltip} from "./marks/tooltip.js"; import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js"; import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; @@ -21,7 +22,7 @@ export function plot(options = {}) { const className = maybeClassName(options.className); // Flatten any nested marks. - const marks = options.marks === undefined ? [] : flatMarks(options.marks); + const marks = options.marks === undefined ? [] : inferTooltip(flatMarks(options.marks)); // Compute the top-level facet state. This has roughly the same structure as // mark-specific facet state, except there isn’t a facetsIndex, and there’s a @@ -127,6 +128,7 @@ export function plot(options = {}) { if (stateByMark.has(mark)) throw new Error("duplicate mark; each mark must be unique"); const {facetsIndex, channels: facetChannels} = facetStateByMark.get(mark) ?? {}; const {data, facets, channels} = mark.initialize(facetsIndex, facetChannels, options); + resolveChannelAliases(channels, stateByMark); applyScaleTransforms(channels, options); stateByMark.set(mark, {data, facets, channels}); } @@ -338,6 +340,36 @@ function flatMarks(marks) { .map(markify); } +// Note: Mutates marks! +function inferTooltip(marks) { + for (const mark of marks) { + if (mark.tooltip) { + marks.push(tooltip(mark.data, tooltipOptions(mark))); + break; + } + } + return marks; +} + +function tooltipOptions(mark) { + const {tooltipAxis: axis, facet, facetAnchor, fx, fy} = mark; + return {axis, x: null, facet, facetAnchor, fx, fy, channels: tooltipChannels(mark)}; +} + +function tooltipChannels(mark) { + return Object.fromEntries(Object.keys(mark.channels).map((name) => [name, {alias: mark}])); +} + +// Note: mutates channels! +function resolveChannelAliases(channels, stateByMark) { + for (const name in channels) { + const channel = channels[name]; + if (channel.alias) { + channels[name] = stateByMark.get(channel.alias).channels[name]; + } + } +} + function markify(mark) { return typeof mark.render === "function" ? mark : new Render(mark); } diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 9cab1c694f..dc16cff656 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -8,54 +8,54 @@ export function stackX(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {y1, y = y1, x, ...rest} = options; // note: consumes x! const [transform, Y, x1, x2] = stack(y, x, "y", "x", stackOptions, rest); - return {...transform, y1, y: Y, x1, x2, x: mid(x1, x2)}; + return {...transform, y1, y: Y, x1, x2, x: mid(x1, x2), tooltipAxis: "y"}; } export function stackX1(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {y1, y = y1, x} = options; const [transform, Y, X] = stack(y, x, "y", "x", stackOptions, options); - return {...transform, y1, y: Y, x: X}; + return {...transform, y1, y: Y, x: X, tooltipAxis: "y"}; } export function stackX2(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {y1, y = y1, x} = options; const [transform, Y, , X] = stack(y, x, "y", "x", stackOptions, options); - return {...transform, y1, y: Y, x: X}; + return {...transform, y1, y: Y, x: X, tooltipAxis: "y"}; } export function stackY(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {x1, x = x1, y, ...rest} = options; // note: consumes y! const [transform, X, y1, y2] = stack(x, y, "x", "y", stackOptions, rest); - return {...transform, x1, x: X, y1, y2, y: mid(y1, y2)}; + return {...transform, x1, x: X, y1, y2, y: mid(y1, y2), tooltipAxis: "x"}; } export function stackY1(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {x1, x = x1, y} = options; const [transform, X, Y] = stack(x, y, "x", "y", stackOptions, options); - return {...transform, x1, x: X, y: Y}; + return {...transform, x1, x: X, y: Y, tooltipAxis: "x"}; } export function stackY2(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); const {x1, x = x1, y} = options; const [transform, X, , Y] = stack(x, y, "x", "y", stackOptions, options); - return {...transform, x1, x: X, y: Y}; + return {...transform, x1, x: X, y: Y, tooltipAxis: "x"}; } export function maybeStackX({x, x1, x2, ...options} = {}) { if (x1 === undefined && x2 === undefined) return stackX({x, ...options}); [x1, x2] = maybeZero(x, x1, x2); - return {...options, x1, x2}; + return {...options, x1, x2, tooltipAxis: "x"}; } export function maybeStackY({y, y1, y2, ...options} = {}) { if (y1 === undefined && y2 === undefined) return stackY({y, ...options}); [y1, y2] = maybeZero(y, y1, y2); - return {...options, y1, y2}; + return {...options, y1, y2, tooltipAxis: "y"}; } // The reverse option is ambiguous: it is both a stack option and a basic diff --git a/test/plots/tooltip.ts b/test/plots/tooltip.ts index bd9ae46eb9..ea0e19581e 100644 --- a/test/plots/tooltip.ts +++ b/test/plots/tooltip.ts @@ -5,8 +5,8 @@ export async function tooltipBin() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); return Plot.plot({ marks: [ - Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight"})), - Plot.tooltip(olympians, Plot.binX({y: "count"}, {x: "weight", axis: "x"})) + Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", tooltip: true})), + // Plot.tooltip(olympians, Plot.binX({y: "count"}, {x: "weight", axis: "x"})) ] }); }