Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

zoom interaction #1738

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/components/PlotRender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/mark.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
126 changes: 76 additions & 50 deletions src/marks/axis.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
}

Expand Down
11 changes: 10 additions & 1 deletion src/marks/dot.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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} = {}) {
Expand Down
25 changes: 23 additions & 2 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand All @@ -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);
}

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions test/document-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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;
});