diff --git a/src/mark.d.ts b/src/mark.d.ts index cfcecc55c8..365b902d38 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -271,6 +271,9 @@ export interface MarkOptions { */ title?: ChannelValue; + /** Whether to generate a tooltip for this mark. */ + tip?: boolean | "x" | "y" | "xy"; + /** * How to clip the mark; one of: * diff --git a/src/mark.js b/src/mark.js index 86a723d789..6e089547da 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,8 +1,8 @@ import {channelDomain, createChannels, valueObject} from "./channel.js"; import {defined} from "./defined.js"; import {maybeFacetAnchor} from "./facet.js"; -import {maybeValue} from "./options.js"; -import {arrayify, isDomainSort, isOptions, keyword, maybeNamed, range, singleton} from "./options.js"; +import {maybeKeyword, maybeNamed, maybeValue} from "./options.js"; +import {arrayify, isDomainSort, isOptions, keyword, range, singleton} from "./options.js"; import {project} from "./projection.js"; import {maybeClip, styles} from "./style.js"; import {basic, initializer} from "./transforms/basic.js"; @@ -24,6 +24,7 @@ export class Mark { marginLeft = margin, clip, channels: extraChannels, + tip, render } = options; this.data = data; @@ -69,6 +70,7 @@ export class Mark { this.marginBottom = +marginBottom; this.marginLeft = +marginLeft; this.clip = maybeClip(clip); + this.tip = maybeTip(tip); // Super-faceting currently disallow position channels; in the future, we // could allow position to be specified in fx and fy in addition to (or // instead of) x and y. @@ -143,3 +145,11 @@ function maybeChannels(channels) { }) ); } + +function maybeTip(tip) { + return tip === true ? "xy" : maybeKeyword(tip, "tip", ["x", "y", "xy"]); +} + +export function withTip(options, tip) { + return options?.tip === true ? {...options, tip} : options; +} diff --git a/src/marks/rule.js b/src/marks/rule.js index 3efaf1a34f..5175e0539e 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -1,8 +1,8 @@ import {create} from "../context.js"; -import {Mark} from "../mark.js"; +import {Mark, withTip} from "../mark.js"; import {identity, number} from "../options.js"; import {isCollapsed} from "../scales.js"; -import {applyDirectStyles, applyIndirectStyles, applyTransform, applyChannelStyles, offset} from "../style.js"; +import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform, offset} from "../style.js"; import {maybeIntervalX, maybeIntervalY} from "../transforms/interval.js"; const defaults = { @@ -21,7 +21,7 @@ export class RuleX extends Mark { y1: {value: y1, scale: "y", optional: true}, y2: {value: y2, scale: "y", optional: true} }, - options, + withTip(options, "x"), defaults ); this.insetTop = number(insetTop); @@ -69,7 +69,7 @@ export class RuleY extends Mark { x1: {value: x1, scale: "x", optional: true}, x2: {value: x2, scale: "x", optional: true} }, - options, + withTip(options, "y"), defaults ); this.insetRight = number(insetRight); diff --git a/src/plot.js b/src/plot.js index 2ae56ee066..7252bc3904 100644 --- a/src/plot.js +++ b/src/plot.js @@ -3,16 +3,19 @@ import {createChannel, inferChannelScale} from "./channel.js"; import {createContext} from "./context.js"; import {createDimensions} from "./dimensions.js"; import {createFacets, recreateFacets, facetExclude, facetGroups, facetTranslator, facetFilter} from "./facet.js"; +import {pointer, pointerX, pointerY} from "./interactions/pointer.js"; 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 {tip} from "./marks/tip.js"; import {arrayify, isColor, isIterable, isNone, isScaleOptions, map, yes, maybeIntervalTransform} from "./options.js"; import {createProjection} from "./projection.js"; import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; import {position, registry as scaleRegistry} from "./scales/index.js"; import {applyInlineStyles, maybeClassName} from "./style.js"; +import {initializer} from "./transforms/basic.js"; import {consumeWarnings, warn} from "./warnings.js"; export function plot(options = {}) { @@ -24,6 +27,9 @@ export function plot(options = {}) { // Flatten any nested marks. const marks = options.marks === undefined ? [] : flatMarks(options.marks); + // Add implicit tips. + marks.push(...inferTips(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 // 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) { } } +function derive(mark, options = {}) { + return initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, context) => { + return context.getMarkState(mark); + }); +} + +function inferTips(marks) { + const tips = []; + for (const mark of marks) { + const t = mark.tip; + if (t) { + const p = t === "x" ? pointerX : t === "y" ? pointerY : pointer; + tips.push(tip(mark.data, p(derive(mark)))); // TODO tip options? + } + } + return tips; +} + function inferAxes(marks, channelsByScale, options) { let { projection, diff --git a/src/transforms/bin.js b/src/transforms/bin.js index 1b3fa210b6..a920b196f1 100644 --- a/src/transforms/bin.js +++ b/src/transforms/bin.js @@ -4,25 +4,27 @@ import { thresholdFreedmanDiaconis, thresholdScott, thresholdSturges, - ticks, tickIncrement, + ticks, utcTickInterval } from "d3"; +import {withTip} from "../mark.js"; import { - valueof, - identity, coerceDate, coerceNumbers, + identity, + isIterable, + isTemporal, + labelof, + map, + maybeApplyInterval, + maybeColorChannel, maybeColumn, maybeRangeInterval, maybeTuple, - maybeColorChannel, maybeValue, mid, - labelof, - isTemporal, - isIterable, - map + valueof } from "../options.js"; import {maybeUtcInterval} from "../time.js"; import {basic} from "./basic.js"; @@ -40,7 +42,6 @@ import { reduceIdentity } from "./group.js"; import {maybeInsetX, maybeInsetY} from "./inset.js"; -import {maybeApplyInterval} from "../options.js"; export function binX(outputs = {y: "count"}, options = {}) { // Group on {z, fill, stroke}, then optionally on y, then bin x. @@ -69,12 +70,12 @@ function maybeDenseInterval(bin, k, options = {}) { : bin({[k]: options?.reduce === undefined ? reduceFirst : options.reduce, filter: null}, options); } -export function maybeDenseIntervalX(options) { - return maybeDenseInterval(binX, "y", options); +export function maybeDenseIntervalX(options = {}) { + return maybeDenseInterval(binX, "y", withTip(options, "x")); } -export function maybeDenseIntervalY(options) { - return maybeDenseInterval(binY, "x", options); +export function maybeDenseIntervalY(options = {}) { + return maybeDenseInterval(binY, "x", withTip(options, "y")); } function binn( diff --git a/src/transforms/stack.js b/src/transforms/stack.js index 9cab1c694f..1550d20e81 100644 --- a/src/transforms/stack.js +++ b/src/transforms/stack.js @@ -1,8 +1,9 @@ -import {InternMap, cumsum, group, groupSort, greatest, max, min, rollup, sum} from "d3"; +import {InternMap, cumsum, greatest, group, groupSort, max, min, rollup, sum} from "d3"; import {ascendingDefined} from "../defined.js"; -import {field, column, maybeColumn, maybeZ, mid, range, valueof, maybeZero, one} from "../options.js"; +import {withTip} from "../mark.js"; +import {maybeApplyInterval, maybeColumn, maybeZ, maybeZero} from "../options.js"; +import {column, field, mid, one, range, valueof} from "../options.js"; import {basic} from "./basic.js"; -import {maybeApplyInterval} from "../options.js"; export function stackX(stackOptions = {}, options = {}) { if (arguments.length === 1) [stackOptions, options] = mergeOptions(stackOptions); @@ -47,12 +48,14 @@ export function stackY2(stackOptions = {}, options = {}) { } export function maybeStackX({x, x1, x2, ...options} = {}) { + options = withTip(options, "y"); if (x1 === undefined && x2 === undefined) return stackX({x, ...options}); [x1, x2] = maybeZero(x, x1, x2); return {...options, x1, x2}; } export function maybeStackY({y, y1, y2, ...options} = {}) { + options = withTip(options, "x"); if (y1 === undefined && y2 === undefined) return stackY({y, ...options}); [y1, y2] = maybeZero(y, y1, y2); return {...options, y1, y2}; diff --git a/test/plots/tip.ts b/test/plots/tip.ts index 93c1f5748d..c6235470fd 100644 --- a/test/plots/tip.ts +++ b/test/plots/tip.ts @@ -2,37 +2,22 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {feature, mesh} from "topojson-client"; -function tipped(mark, options = {}, pointer = Plot.pointer) { - return Plot.marks(mark, Plot.tip(mark.data, pointer(derive(mark, options)))); -} - -function tippedX(mark, options = {}) { - return tipped(mark, options, Plot.pointerX); -} - -function tippedY(mark, options = {}) { - return tipped(mark, options, Plot.pointerY); -} - -function derive(mark, options = {}) { - return Plot.initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, context) => { - return (context as any).getMarkState(mark); - }); -} - export async function tipBar() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); - return tippedY(Plot.barX(olympians, Plot.groupY({x: "count"}, {y: "sport", sort: {y: "x"}}))).plot({marginLeft: 100}); + return Plot.plot({ + marginLeft: 100, + marks: [Plot.barX(olympians, Plot.groupY({x: "count"}, {y: "sport", sort: {y: "x"}, tip: true}))] + }); } export async function tipBin() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); - return tippedX(Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight"}))).plot(); + return Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", tip: true})).plot(); } export async function tipBinStack() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); - return tippedX(Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex"}))).plot(); + return Plot.rectY(olympians, Plot.binX({y: "count"}, {x: "weight", fill: "sex", tip: true})).plot(); } export async function tipCell() { @@ -41,7 +26,7 @@ export async function tipCell() { height: 400, marginLeft: 100, color: {scheme: "blues"}, - marks: [tippedY(Plot.cell(olympians, Plot.group({fill: "count"}, {x: "sex", y: "sport"})))] + marks: [Plot.cell(olympians, Plot.group({fill: "count"}, {x: "sex", y: "sport", tip: "y"}))] }); } @@ -51,18 +36,18 @@ export async function tipCellFacet() { height: 400, marginLeft: 100, color: {scheme: "blues"}, - marks: [tippedY(Plot.cell(olympians, Plot.groupY({fill: "count"}, {fx: "sex", y: "sport"})))] + marks: [Plot.cell(olympians, Plot.groupY({fill: "count"}, {fx: "sex", y: "sport", tip: "y"}))] }); } export async function tipDodge() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return tipped(Plot.dot(penguins, Plot.dodgeY({x: "culmen_length_mm", r: "body_mass_g"}))).plot({height: 160}); + return Plot.dot(penguins, Plot.dodgeY({x: "culmen_length_mm", r: "body_mass_g", tip: true})).plot({height: 160}); } export async function tipDot() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return tipped(Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"})).plot(); + return Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex", tip: true}).plot(); } export async function tipDotFacets() { @@ -74,15 +59,14 @@ export async function tipDotFacets() { interval: "10 years" }, marks: [ - tipped( - Plot.dot(athletes, { - x: "weight", - y: "height", - fx: "sex", - fy: "date_of_birth", - channels: {name: "name", sport: "sport"} - }) - ) + Plot.dot(athletes, { + x: "weight", + y: "height", + fx: "sex", + fy: "date_of_birth", + channels: {name: "name", sport: "sport"}, + tip: true + }) ] }); } @@ -90,9 +74,12 @@ export async function tipDotFacets() { export async function tipDotFilter() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); const xy = {x: "culmen_length_mm", y: "culmen_depth_mm", stroke: "sex"}; - const [dot1, tip1] = tipped(Plot.dot(penguins, {...xy, filter: (d) => d.sex === "MALE"}), {anchor: "left"}); - const [dot2, tip2] = tipped(Plot.dot(penguins, {...xy, filter: (d) => d.sex === "FEMALE"}), {anchor: "right"}); - return Plot.marks(dot1, dot2, tip1, tip2).plot(); + return Plot.plot({ + marks: [ + Plot.dot(penguins, {...xy, filter: (d) => d.sex === "MALE", tip: true}), + Plot.dot(penguins, {...xy, filter: (d) => d.sex === "FEMALE", tip: true}) + ] + }); } export async function tipGeoCentroid() { @@ -119,12 +106,12 @@ export async function tipGeoCentroid() { export async function tipHexbin() { const olympians = await d3.csv("data/athletes.csv", d3.autoType); - return tipped(Plot.hexagon(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height"}))).plot(); + return Plot.hexagon(olympians, Plot.hexbin({r: "count"}, {x: "weight", y: "height", tip: true})).plot(); } export async function tipLine() { const aapl = await d3.csv("data/aapl.csv", d3.autoType); - return tippedX(Plot.lineY(aapl, {x: "Date", y: "Close"})).plot(); + return Plot.lineY(aapl, {x: "Date", y: "Close", tip: true}).plot(); } export async function tipRaster() { @@ -135,11 +122,11 @@ export async function tipRaster() { height: 484, projection: {type: "reflect-y", inset: 3, domain}, color: {type: "diverging"}, - marks: [tipped(Plot.raster(ca55, {x: "GRID_EAST", y: "GRID_NORTH", fill: "MAG_IGRF90", interpolate: "nearest"}))] + marks: [Plot.raster(ca55, {x: "GRID_EAST", y: "GRID_NORTH", fill: "MAG_IGRF90", interpolate: "nearest", tip: true})] }); } export async function tipRule() { const penguins = await d3.csv("data/penguins.csv", d3.autoType); - return tippedX(Plot.ruleX(penguins, {x: "body_mass_g"})).plot(); + return Plot.ruleX(penguins, {x: "body_mass_g", tip: true}).plot(); }