From ceb54c3a25c01e3f2c5a3cbc290d38ba29851c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 7 Jul 2023 12:37:42 +0200 Subject: [PATCH 1/2] zoom: first pass --- src/mark.js | 19 +++++++ src/marks/axis.js | 126 ++++++++++++++++++++++++++++------------------ src/marks/dot.js | 11 +++- src/plot.js | 25 ++++++++- 4 files changed, 128 insertions(+), 53 deletions(-) diff --git a/src/mark.js b/src/mark.js index c815f76be0..436831ee98 100644 --- a/src/mark.js +++ b/src/mark.js @@ -1,3 +1,4 @@ +import {select} from "d3"; import {channelDomain, createChannels, valueObject} from "./channel.js"; import {defined} from "./defined.js"; import {maybeFacetAnchor} from "./facet.js"; @@ -128,6 +129,24 @@ export class Mark { if (context.projection) this.project(channels, values, context); return values; } + // On zoom, a mark can do more interesting things than just applying a + // transform; for instance, an axis mark might want to adapt its ticks, and a + // dot mark might adjust its radius. By default, though, we just zoom the + // zoomable SVG elements (ie everything but clipPath?). + zoom(node, transform) { + let z = select(node).selectAll(".zoomable"); + if (z.size() === 0) { + z = select(node).append("g").classed("zoomable", true); + select(node) + .selectChildren("circle,g:not(.zoomable),image,line,path,rect,text") + .attr("vector-effect", "non-scaling-stroke") + .each(function () { + z.append(() => this); + }); + } + z.attr("transform", transform); + return node; + } } export function marks(...marks) { diff --git a/src/marks/axis.js b/src/marks/axis.js index 2129fd974b..7c04ab70d8 100644 --- a/src/marks/axis.js +++ b/src/marks/axis.js @@ -1,4 +1,4 @@ -import {InternSet, extent, format, utcFormat} from "d3"; +import {InternSet, extent, format, scaleIdentity, utcFormat} from "d3"; import {formatDefault} from "../format.js"; import {marks} from "../mark.js"; import {radians} from "../math.js"; @@ -130,32 +130,35 @@ function axisKy( }) : null, !isNoneish(fill) && label !== null - ? text( - [], - labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) { - const scale = scales[k]; - const {marginTop, marginRight, marginBottom, marginLeft} = (k === "y" && dimensions.inset) || dimensions; - const cla = labelAnchor ?? (scale.bandwidth ? "center" : "top"); - const clo = labelOffset ?? (anchor === "right" ? marginRight : marginLeft) - 3; - if (cla === "center") { - this.textAnchor = undefined; // middle - this.lineAnchor = anchor === "right" ? "bottom" : "top"; - this.frameAnchor = anchor; - this.rotate = -90; - } else { - this.textAnchor = anchor === "right" ? "end" : "start"; - this.lineAnchor = cla; - this.frameAnchor = `${cla}-${anchor}`; - this.rotate = 0; - } - this.dy = cla === "top" ? 3 - marginTop : cla === "bottom" ? marginBottom - 3 : 0; - this.dx = anchor === "right" ? clo : -clo; - this.ariaLabel = `${k}-axis label`; - return { - facets: [[0]], - channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}} - }; - }) + ? Object.assign( + text( + [], + labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) { + const scale = scales[k]; + const {marginTop, marginRight, marginBottom, marginLeft} = (k === "y" && dimensions.inset) || dimensions; + const cla = labelAnchor ?? (scale.bandwidth ? "center" : "top"); + const clo = labelOffset ?? (anchor === "right" ? marginRight : marginLeft) - 3; + if (cla === "center") { + this.textAnchor = undefined; // middle + this.lineAnchor = anchor === "right" ? "bottom" : "top"; + this.frameAnchor = anchor; + this.rotate = -90; + } else { + this.textAnchor = anchor === "right" ? "end" : "start"; + this.lineAnchor = cla; + this.frameAnchor = `${cla}-${anchor}`; + this.rotate = 0; + } + this.dy = cla === "top" ? 3 - marginTop : cla === "bottom" ? marginBottom - 3 : 0; + this.dx = anchor === "right" ? clo : -clo; + this.ariaLabel = `${k}-axis label`; + return { + facets: [[0]], + channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}} + }; + }) + ), + {zoom: null} ) : null ); @@ -231,29 +234,32 @@ function axisKx( }) : null, !isNoneish(fill) && label !== null - ? text( - [], - labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) { - const scale = scales[k]; - const {marginTop, marginRight, marginBottom, marginLeft} = (k === "x" && dimensions.inset) || dimensions; - const cla = labelAnchor ?? (scale.bandwidth ? "center" : "right"); - const clo = labelOffset ?? (anchor === "top" ? marginTop : marginBottom) - 3; - if (cla === "center") { - this.frameAnchor = anchor; - this.textAnchor = undefined; // middle - } else { - this.frameAnchor = `${anchor}-${cla}`; - this.textAnchor = cla === "right" ? "end" : "start"; - } - this.lineAnchor = anchor; - this.dy = anchor === "top" ? -clo : clo; - this.dx = cla === "right" ? marginRight - 3 : cla === "left" ? 3 - marginLeft : 0; - this.ariaLabel = `${k}-axis label`; - return { - facets: [[0]], - channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}} - }; - }) + ? Object.assign( + text( + [], + labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) { + const scale = scales[k]; + const {marginTop, marginRight, marginBottom, marginLeft} = (k === "x" && dimensions.inset) || dimensions; + const cla = labelAnchor ?? (scale.bandwidth ? "center" : "right"); + const clo = labelOffset ?? (anchor === "top" ? marginTop : marginBottom) - 3; + if (cla === "center") { + this.frameAnchor = anchor; + this.textAnchor = undefined; // middle + } else { + this.frameAnchor = `${anchor}-${cla}`; + this.textAnchor = cla === "right" ? "end" : "start"; + } + this.lineAnchor = anchor; + this.dy = anchor === "top" ? -clo : clo; + this.dx = cla === "right" ? marginRight - 3 : cla === "left" ? 3 - marginLeft : 0; + this.ariaLabel = `${k}-axis label`; + return { + facets: [[0]], + channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}} + }; + }) + ), + {zoom: null} ) : null ); @@ -522,8 +528,10 @@ function labelOptions( function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { let channels; + let u; function axisInitializer(data, facets, _channels, scales, dimensions, context) { + u = arguments; const initializeFacets = data == null && (k === "fx" || k === "fy"); const {[k]: scale} = scales; if (!scale) throw new Error(`missing scale: ${k}`); @@ -617,6 +625,24 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) { } m.ariaLabel = ariaLabel; if (m.clip === undefined) m.clip = false; // don’t clip axes by default + m.zoom = function (g, transform) { + if (!(k === "x" || k === "y")) return g; + const [, , , {[k]: scale}, dimensions, context] = [...u]; + if (scale.bandwidth) return g; // TODO ordinal scales? + const scale2 = transform[k === "x" ? "rescaleX" : "rescaleY"](scale ?? scaleIdentity()); + const ticks = scale2.ticks(); + g.replaceWith( + (g = m.render.call( + m, + ticks.map((d, i) => i), + {[k]: scale2}, + {[k]: ticks.map(scale2), text: ticks}, + dimensions, + context + )) + ); + return g; + }; return m; } diff --git a/src/marks/dot.js b/src/marks/dot.js index 58e529def2..dce55ecd0d 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -1,4 +1,4 @@ -import {pathRound as path, symbolCircle} from "d3"; +import {pathRound as path, select, symbolCircle} from "d3"; import {create} from "../context.js"; import {negative, positive} from "../defined.js"; import {Mark} from "../mark.js"; @@ -136,6 +136,15 @@ export class Dot extends Mark { ) .node(); } + zoom(node, transform, values) { + const a = 1 / Math.sqrt(transform.k); + select(node) + .attr("transform", transform) + .selectAll("circle") + .attr("vector-effect", "non-scaling-stroke") + .attr("r", values.r ? (i) => a * values.r[i] : this.r * a); + return node; + } } export function dot(data, {x, y, ...options} = {}) { diff --git a/src/plot.js b/src/plot.js index 541218f306..b814b4758f 100644 --- a/src/plot.js +++ b/src/plot.js @@ -1,4 +1,4 @@ -import {creator, select} from "d3"; +import {creator, select, zoom as zoomer} from "d3"; import {createChannel, inferChannelScale} from "./channel.js"; import {createContext} from "./context.js"; import {createDimensions} from "./dimensions.js"; @@ -20,7 +20,7 @@ import {initializer} from "./transforms/basic.js"; import {consumeWarnings, warn} from "./warnings.js"; export function plot(options = {}) { - const {facet, style, title, subtitle, caption, ariaLabel, ariaDescription} = options; + const {facet, style, title, subtitle, caption, ariaLabel, ariaDescription, zoom} = options; // className for inline styles const className = maybeClassName(options.className); @@ -273,6 +273,7 @@ export function plot(options = {}) { .call(applyInlineStyles, style); // Render marks. + const nodesByMark = new Map(); for (const mark of marks) { const {channels, values, facets: indexes} = stateByMark.get(mark); @@ -286,6 +287,7 @@ export function plot(options = {}) { } const node = mark.render(index, scales, values, superdimensions, context); if (node == null) continue; + nodesByMark.set(mark, node); svg.appendChild(node); } @@ -337,6 +339,25 @@ export function plot(options = {}) { figure.scale = exposeScales(scales.scales); figure.legend = exposeLegends(scaleDescriptors, context, options); + if (zoom || true) { + select(svg).call( + zoomer().on("start zoom end", ({transform}) => { + // todo also opt-out when a scale is collapsed. + if (scales.y?.bandwidth || zoom === "x") { + transform.toString = () => `translate(${transform.x},${0})scale(${transform.k},1)`; + transform.rescaleY = (y) => y; + } + if (scales.x?.bandwidth || zoom === "y") { + transform.toString = () => `translate(${0},${transform.y})scale(1,${transform.k})`; + transform.rescaleX = (x) => x; + } + for (const [mark, node] of nodesByMark) { + if (mark.zoom != null) nodesByMark.set(mark, mark.zoom(node, transform, stateByMark.get(mark).values)); + } + }) + ); + } + const w = consumeWarnings(); if (w > 0) { select(svg) From 3984689b7822e7b0a28c8595e2e3501f4f16efe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 21 Dec 2023 10:53:16 +0100 Subject: [PATCH 2/2] let tests pass, build docs --- docs/components/PlotRender.js | 1 + test/document-test.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/docs/components/PlotRender.js b/docs/components/PlotRender.js index 519d1f678f..fc42572f08 100644 --- a/docs/components/PlotRender.js +++ b/docs/components/PlotRender.js @@ -4,6 +4,7 @@ import {h, withDirectives} from "vue"; class Document { constructor() { this.documentElement = new Element(this, "html"); + if (!globalThis.navigator) globalThis.navigator = {}; } createElementNS(namespace, tagName) { return new Element(this, tagName); diff --git a/test/document-test.js b/test/document-test.js index 0624ddc575..25c091a91e 100644 --- a/test/document-test.js +++ b/test/document-test.js @@ -4,20 +4,26 @@ import {JSDOM} from "jsdom"; it("Plot.plot supports the document option", () => { const {window} = new JSDOM(""); + global.navigator = window.navigator; const svg = Plot.plot({document: window.document, marks: [Plot.barY([1, 2, 4, 3])]}); assert.strictEqual(svg.ownerDocument, window.document); + delete global.navigator; }); it("Plot.plot supports the document option for inline legends", () => { const {window} = new JSDOM(""); + global.navigator = window.navigator; const figure = Plot.plot({document: window.document, color: {legend: true}, marks: [Plot.cellX([1, 2, 4, 3])]}); assert.strictEqual(figure.ownerDocument, window.document); + delete global.navigator; }); it("mark.plot supports the document option", () => { const {window} = new JSDOM(""); + global.navigator = window.navigator; const svg = Plot.barY([1, 2, 4, 3]).plot({document: window.document}); assert.strictEqual(svg.ownerDocument, window.document); + delete global.navigator; }); it("Plot.legend supports the document option", () => { @@ -29,32 +35,40 @@ it("Plot.legend supports the document option", () => { it("plot.legend supports the document option for quantitative color scales", () => { const {window: window1} = new JSDOM(""); const {window: window2} = new JSDOM(""); + global.navigator = window1.navigator; const svg = Plot.plot({document: window1.document, marks: [Plot.cellX([1, 2, 4, 3])]}).legend("color", { document: window2.document }); assert.strictEqual(svg.ownerDocument, window2.document); + delete global.navigator; }); it("plot.legend inherits the document option", () => { const {window} = new JSDOM(""); + global.navigator = window.navigator; const svg = Plot.plot({document: window.document, marks: [Plot.cellX([1, 2, 4, 3])]}).legend("color"); assert.strictEqual(svg.ownerDocument, window.document); + delete global.navigator; }); it("plot.legend inherits the document option if that option is present but undefined", () => { const {window} = new JSDOM(""); + global.navigator = window.navigator; const svg = Plot.plot({document: window.document, marks: [Plot.cellX([1, 2, 4, 3])]}).legend("color", { document: undefined }); assert.strictEqual(svg.ownerDocument, window.document); + delete global.navigator; }); it("plot.legend supports the document option for categorical color scales", () => { const {window} = new JSDOM(""); + global.navigator = window.navigator; const svg = Plot.plot({ document: window.document, color: {type: "categorical"}, marks: [Plot.cellX([1, 2, 4, 3])] }).legend("color"); assert.strictEqual(svg.ownerDocument, window.document); + delete global.navigator; });