diff --git a/src/mark.js b/src/mark.js index ec54b70800..184b84a77a 100644 --- a/src/mark.js +++ b/src/mark.js @@ -81,6 +81,20 @@ export class Mark { if (this.transform != null) ({facets, data} = this.transform(data, facets)), (data = arrayify(data)); const channels = Channels(this.channels, data); if (this.sort != null) channelDomain(channels, facetChannels, data, this.sort); // mutates facetChannels! + + // TODO design the default details channel + channels.details ??= { + value: channels.x + ? Array.from(channels.x.value, (x, i) => ({ + x, + y: channels.y?.value[i], + fill: channels.fill?.value[i], + stroke: channels.stroke?.value[i], + text: channels.text?.value[i] + })) + : null, + filter: null + }; return {data, facets, channels}; } filter(index, channels, values) { diff --git a/src/style.js b/src/style.js index 09185365c8..db5d9c5b49 100644 --- a/src/style.js +++ b/src/style.js @@ -1,4 +1,4 @@ -import {geoPath, group, namespaces} from "d3"; +import {geoPath, group, local, namespaces} from "d3"; import {create} from "./context.js"; import {defined, nonempty} from "./defined.js"; import {formatDefault} from "./format.js"; @@ -46,7 +46,8 @@ export function styles( mixBlendMode, paintOrder, pointerEvents, - shapeRendering + shapeRendering, + details }, { ariaLabel: cariaLabel, @@ -148,7 +149,8 @@ export function styles( stroke: {value: vstroke, scale: "auto", optional: true}, strokeOpacity: {value: vstrokeOpacity, scale: "opacity", optional: true}, strokeWidth: {value: vstrokeWidth, optional: true}, - opacity: {value: vopacity, scale: "opacity", optional: true} + opacity: {value: vopacity, scale: "opacity", optional: true}, + details: {value: details, optional: true} }; } @@ -190,7 +192,10 @@ export function applyChannelStyles( strokeOpacity: SO, strokeWidth: SW, opacity: O, - href: H + href: H, + details: D, + x: X, + y: Y } ) { if (AL) applyAttr(selection, "aria-label", (i) => AL[i]); @@ -201,9 +206,20 @@ export function applyChannelStyles( if (SW) applyAttr(selection, "stroke-width", (i) => SW[i]); if (O) applyAttr(selection, "opacity", (i) => O[i]); if (H) applyHref(selection, (i) => H[i], target); + if (D) applyDetails(selection, D, X, Y); applyTitle(selection, T); } +const details = local(); // TODO: this should belong to the plot context +const geometry = local(); +function applyDetails(selection, D, X, Y) { + for (const node of selection) { + const i = node.__data__; + details.set(node, D[i]); + geometry.set(node, {x: X[i], y: Y[i]}); + } +} + export function applyGroupedChannelStyles( selection, {target}, diff --git a/test/plots/penguin-culmen.js b/test/plots/penguin-culmen.js index ed499087c8..c511bbdb32 100644 --- a/test/plots/penguin-culmen.js +++ b/test/plots/penguin-culmen.js @@ -1,5 +1,7 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; +import {pointers, quadtree} from "d3"; +import {create} from "d3"; export default async function () { const penguins = await d3.csv("data/penguins.csv", d3.autoType); @@ -14,8 +16,57 @@ export default async function () { }, marks: [ Plot.frame(), - Plot.dot(penguins, {facet: "exclude", x: "culmen_depth_mm", y: "culmen_length_mm", r: 2, fill: "#ddd"}), - Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm"}) + Plot.dot(penguins, { + facet: "exclude", + x: "culmen_depth_mm", + y: "culmen_length_mm", + r: 2, + fill: "#ddd", + details: false // TODO null + }), + Plot.dot(penguins, {x: "culmen_depth_mm", y: "culmen_length_mm", details: Plot.identity}), + new Tooltip() ] }); } + +class Tooltip extends Plot.Mark { + render(I, S, C, {width, height, marginLeft, marginTop, marginRight, marginBottom}) { + const g = create("svg:g"); + const popup = g + .append("svg:foreignObject") + .attr("width", 150) + .attr("height", 200) + .attr("x", marginLeft) + .attr("y", marginTop); + const div = popup.append("xhtml:div"); + g.append("svg:rect") + .attr("x", marginLeft) + .attr("width", width - marginLeft - marginRight) + .attr("y", marginTop) + .attr("height", height - marginTop - marginBottom) + .attr("fill", "none") + .attr("pointer-events", "all") + .on("pointermove", function (event) { + if (!this.__quadtree) { + this.__quadtree = quadtree(); + for (const node of g.node().parentNode.querySelectorAll("*")) { + // @1: details; + // @2: geometry; + if (node["@1"]) { + console.warn("adding", node["@1"], node["@2"]); + this.__quadtree.add([node["@2"].x, node["@2"].y, node["@1"]]); + } + } + } + const [[x, y]] = pointers(event); + const [, , closest] = this.__quadtree.find(x, y); + div.html( + Object.entries(closest) + .map(([k, v]) => [k, v].join(": ")) + .join("
") + ); + }); + return g.node(); + } +}