From c5c562ea3b8b32c0bd809f959f5801834c8aea67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 1 Nov 2024 14:50:04 +0100 Subject: [PATCH 1/3] Wrap any rendered SVGSVGElement in a SVGGElement closes #2218 --- docs/features/marks.md | 2 +- src/plot.js | 20 +- test/output/nestedFacets.html | 1012 +++++++++++++++++++++++++++++++++ test/plots/index.ts | 1 + test/plots/nested-facets.ts | 53 ++ 5 files changed, 1084 insertions(+), 4 deletions(-) create mode 100644 test/output/nestedFacets.html create mode 100644 test/plots/nested-facets.ts diff --git a/docs/features/marks.md b/docs/features/marks.md index 4d9cb90cd4..7e163907e6 100644 --- a/docs/features/marks.md +++ b/docs/features/marks.md @@ -114,7 +114,7 @@ Plot.plot({ ``` ::: -Marks may also be a function which returns an SVG element, if you wish to insert arbitrary content. (Here we use [Hypertext Literal](https://github.com/observablehq/htl) to generate an SVG gradient.) +Marks may also be a function which returns an [SVG element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element), if you wish to insert arbitrary content. (Here we use [Hypertext Literal](https://github.com/observablehq/htl) to generate an SVG gradient.) :::plot defer https://observablehq.com/@observablehq/plot-gradient-bars ```js diff --git a/src/plot.js b/src/plot.js index a7383a66c7..bd2cdbf960 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,6 +1,6 @@ import {creator, select} from "d3"; import {createChannel, inferChannelScale} from "./channel.js"; -import {createContext} from "./context.js"; +import {create, 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"; @@ -284,7 +284,7 @@ export function plot(options = {}) { index = mark.filter(index, channels, values); if (index.length === 0) continue; } - const node = mark.render(index, scales, values, superdimensions, context); + const node = maybeWrapSVGElement(mark.render(index, scales, values, superdimensions, context), context); if (node == null) continue; svg.appendChild(node); } @@ -303,7 +303,7 @@ export function plot(options = {}) { if (!faceted && index === indexes[0]) index = subarray(index); // copy before assigning fx, fy, fi (index.fx = f.x), (index.fy = f.y), (index.fi = f.i); } - const node = mark.render(index, scales, values, subdimensions, context); + const node = maybeWrapSVGElement(mark.render(index, scales, values, subdimensions, context), context); if (node == null) continue; // Lazily construct the shared group (to drop empty marks). (g ??= select(svg).append("g")).append(() => node).datum(f); @@ -738,3 +738,17 @@ function outerRange(scale) { if (x2 < x1) [x1, x2] = [x2, x1]; return [x1, x2 + scale.bandwidth()]; } + +// Wrap any SVGSVGElement in a SVGGElement, in order to support the transform +// attribute required for faceting. +function maybeWrapSVGElement(node, context) { + if (!node) return null; + if (node.nodeType === 1 && node.namespaceURI === "http://www.w3.org/2000/svg") { + if (node.tagName === "svg") + return create("svg:g", context) + .append(() => node) + .node().parentElement; + return node; + } + throw new Error(`Unsupported render ${node.tagName}`); +} diff --git a/test/output/nestedFacets.html b/test/output/nestedFacets.html new file mode 100644 index 0000000000..120e2ce7e1 --- /dev/null +++ b/test/output/nestedFacets.html @@ -0,0 +1,1012 @@ +
+ + + + + + + + + + IF + + + + SI1 + + + + I1 + + + clarity + + + + + + 52 + 54 + 56 + 58 + 60 + 62 + 64 + 66 + 68 + 70 + + + + depth → + + + + D + + + E + + + F + + + + color + + + + + + + + + + + + Fair + + + Good + + + Ideal + + + Premium + + + Very Good + + + + cut + + + + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + + clarity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fair + + + Good + + + Ideal + + + Premium + + + Very Good + + + + cut + + + + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fair + + + Good + + + Ideal + + + Premium + + + Very Good + + + + cut + + + + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/test/plots/index.ts b/test/plots/index.ts index b13d1ec45b..3176991d75 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -184,6 +184,7 @@ export * from "./movies-profit-by-genre.js"; export * from "./movies-rating-by-genre.js"; export * from "./multiplication-table.js"; export * from "./music-revenue.js"; +export * from "./nested-facets.js"; export * from "./npm-versions.js"; export * from "./opacity.js"; export * from "./ordinal-bar.js"; diff --git a/test/plots/nested-facets.ts b/test/plots/nested-facets.ts new file mode 100644 index 0000000000..fa952a4464 --- /dev/null +++ b/test/plots/nested-facets.ts @@ -0,0 +1,53 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function nestedFacets() { + const diamonds = await d3.csv("data/diamonds.csv", d3.autoType); + return Plot.plot({ + width: 960, + height: 480, + fx: {domain: ["D", "E", "F"]}, + color: {legend: "ramp", domain: ["IF", "SI1", "I1"]}, + y: {domain: [51, 71.9], insetTop: 20, labelAnchor: "center"}, + marginLeft: 40, + marginBottom: 40, + marginTop: 35, + marks: [ + Plot.axisFx({anchor: "top"}), + Plot.frame({anchor: "top", strokeOpacity: 1}), + Plot.dot(diamonds, { + fx: "color", // outer x facet + y: "depth", // shared y scale + fill: "clarity", // shared color scale + render(index, {scales}, _values, {facet, ...dimensions}) { + const data = Array.from(index, (i) => this.data[i]); // subplot dataset as a subset of the data + return Plot.plot({ + ...dimensions, + marginTop: 60, + ...scales, // shared color scale, shared y scale + fx: {axis: "bottom", paddingOuter: 0.1, paddingInner: 0.2}, // inner x facet + x: { + domain: scales.color.domain, + axis: "top", + labelAnchor: "left", + labelOffset: 16, + ...(index["fi"] && {label: null}), + grid: true, + tickSize: 0 + }, // new x scale with a common domain and additional axis options + y: {...scales.y, grid: 4, axis: null}, // shared y scale with additional options + marks: [ + Plot.frame({anchor: "bottom"}), + Plot.boxY(data, { + fx: "cut", + x: "clarity", + y: "depth", + fill: "clarity" + }) + ] + }) as SVGElement; + } + }) + ] + }); +} From 1be403852717e8f51842b3278e23afa2c598d9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Tue, 12 Nov 2024 19:23:14 +0100 Subject: [PATCH 2/3] use x, y instead of a wrapper (review suggestion) --- src/facet.js | 15 +- src/plot.js | 22 +- test/output/nestedFacets.html | 1814 ++++++++++++++++----------------- 3 files changed, 919 insertions(+), 932 deletions(-) diff --git a/src/facet.js b/src/facet.js index 5398b344e7..ae4498ba2f 100644 --- a/src/facet.js +++ b/src/facet.js @@ -1,6 +1,7 @@ import {InternMap, cross, rollup, sum} from "d3"; import {keyof, map, range} from "./options.js"; import {createScales} from "./scales.js"; +import {template} from "./template.js"; // Returns an array of {x?, y?, i} objects representing the facet domain. export function createFacets(channelsByScale, options) { @@ -63,11 +64,15 @@ export function facetGroups(data, {fx, fy}) { } export function facetTranslator(fx, fy, {marginTop, marginLeft}) { - return fx && fy - ? ({x, y}) => `translate(${fx(x) - marginLeft},${fy(y) - marginTop})` - : fx - ? ({x}) => `translate(${fx(x) - marginLeft},0)` - : ({y}) => `translate(0,${fy(y) - marginTop})`; + const x = fx ? ({x}) => fx(x) - marginLeft : () => 0; + const y = fy ? ({y}) => fy(y) - marginTop : () => 0; + const t = template`translate(${fx ? x : 0},${fy ? y : 0})`; + return function (d) { + if (this.tagName === "svg") { + this.setAttribute("x", x(d)); + this.setAttribute("y", y(d)); + } else this.setAttribute("transform", t(d)); + }; } // Returns an index that for each facet lists all the elements present in other diff --git a/src/plot.js b/src/plot.js index bd2cdbf960..829c4624a4 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,6 +1,6 @@ import {creator, select} from "d3"; import {createChannel, inferChannelScale} from "./channel.js"; -import {create, createContext} from "./context.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"; @@ -284,7 +284,7 @@ export function plot(options = {}) { index = mark.filter(index, channels, values); if (index.length === 0) continue; } - const node = maybeWrapSVGElement(mark.render(index, scales, values, superdimensions, context), context); + const node = mark.render(index, scales, values, superdimensions, context); if (node == null) continue; svg.appendChild(node); } @@ -303,7 +303,7 @@ export function plot(options = {}) { if (!faceted && index === indexes[0]) index = subarray(index); // copy before assigning fx, fy, fi (index.fx = f.x), (index.fy = f.y), (index.fi = f.i); } - const node = maybeWrapSVGElement(mark.render(index, scales, values, subdimensions, context), context); + const node = mark.render(index, scales, values, subdimensions, context); if (node == null) continue; // Lazily construct the shared group (to drop empty marks). (g ??= select(svg).append("g")).append(() => node).datum(f); @@ -317,7 +317,7 @@ export function plot(options = {}) { } } } - g?.selectChildren().attr("transform", facetTranslate); + g?.selectChildren().each(facetTranslate); } } @@ -738,17 +738,3 @@ function outerRange(scale) { if (x2 < x1) [x1, x2] = [x2, x1]; return [x1, x2 + scale.bandwidth()]; } - -// Wrap any SVGSVGElement in a SVGGElement, in order to support the transform -// attribute required for faceting. -function maybeWrapSVGElement(node, context) { - if (!node) return null; - if (node.nodeType === 1 && node.namespaceURI === "http://www.w3.org/2000/svg") { - if (node.tagName === "svg") - return create("svg:g", context) - .append(() => node) - .node().parentElement; - return node; - } - throw new Error(`Unsupported render ${node.tagName}`); -} diff --git a/test/output/nestedFacets.html b/test/output/nestedFacets.html index 120e2ce7e1..78280a3cce 100644 --- a/test/output/nestedFacets.html +++ b/test/output/nestedFacets.html @@ -97,916 +97,912 @@ - - - + :where(.plot text), + :where(.plot tspan) { + white-space: pre; + } + + + + Fair + + + Good + + + Ideal + + + Premium + + + Very Good + + + + cut + + + + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + IF + SI1 + I1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 1abad861b60bec28a437e8d5612380cb06d598aa Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Thu, 14 Nov 2024 18:09:14 -0800 Subject: [PATCH 3/3] inline template --- src/facet.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/facet.js b/src/facet.js index ae4498ba2f..cc3af59ac4 100644 --- a/src/facet.js +++ b/src/facet.js @@ -1,7 +1,6 @@ import {InternMap, cross, rollup, sum} from "d3"; import {keyof, map, range} from "./options.js"; import {createScales} from "./scales.js"; -import {template} from "./template.js"; // Returns an array of {x?, y?, i} objects representing the facet domain. export function createFacets(channelsByScale, options) { @@ -66,12 +65,13 @@ export function facetGroups(data, {fx, fy}) { export function facetTranslator(fx, fy, {marginTop, marginLeft}) { const x = fx ? ({x}) => fx(x) - marginLeft : () => 0; const y = fy ? ({y}) => fy(y) - marginTop : () => 0; - const t = template`translate(${fx ? x : 0},${fy ? y : 0})`; return function (d) { if (this.tagName === "svg") { this.setAttribute("x", x(d)); this.setAttribute("y", y(d)); - } else this.setAttribute("transform", t(d)); + } else { + this.setAttribute("transform", `translate(${x(d)},${y(d)})`); + } }; }