Skip to content

Commit a220288

Browse files
committed
zoom: first pass
1 parent 7b92d54 commit a220288

File tree

4 files changed

+128
-53
lines changed

4 files changed

+128
-53
lines changed

src/mark.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {select} from "d3";
12
import {channelDomain, createChannels, valueObject} from "./channel.js";
23
import {defined} from "./defined.js";
34
import {maybeFacetAnchor} from "./facet.js";
@@ -128,6 +129,24 @@ export class Mark {
128129
if (context.projection) this.project(channels, values, context);
129130
return values;
130131
}
132+
// On zoom, a mark can do more interesting things than just applying a
133+
// transform; for instance, an axis mark might want to adapt its ticks, and a
134+
// dot mark might adjust its radius. By default, though, we just zoom the
135+
// zoomable SVG elements (ie everything but clipPath?).
136+
zoom(node, transform) {
137+
let z = select(node).selectAll(".zoomable");
138+
if (z.size() === 0) {
139+
z = select(node).append("g").classed("zoomable", true);
140+
select(node)
141+
.selectChildren("circle,g:not(.zoomable),image,line,path,rect,text")
142+
.attr("vector-effect", "non-scaling-stroke")
143+
.each(function () {
144+
z.append(() => this);
145+
});
146+
}
147+
z.attr("transform", transform);
148+
return node;
149+
}
131150
}
132151

133152
export function marks(...marks) {

src/marks/axis.js

Lines changed: 76 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {extent, format, timeFormat, utcFormat} from "d3";
1+
import {extent, format, scaleIdentity, timeFormat, utcFormat} from "d3";
22
import {formatDefault} from "../format.js";
33
import {marks} from "../mark.js";
44
import {radians} from "../math.js";
@@ -129,32 +129,35 @@ function axisKy(
129129
})
130130
: null,
131131
!isNoneish(fill) && label !== null
132-
? text(
133-
[],
134-
labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) {
135-
const scale = scales[k];
136-
const {marginTop, marginRight, marginBottom, marginLeft} = (k === "y" && dimensions.inset) || dimensions;
137-
const cla = labelAnchor ?? (scale.bandwidth ? "center" : "top");
138-
const clo = labelOffset ?? (anchor === "right" ? marginRight : marginLeft) - 3;
139-
if (cla === "center") {
140-
this.textAnchor = undefined; // middle
141-
this.lineAnchor = anchor === "right" ? "bottom" : "top";
142-
this.frameAnchor = anchor;
143-
this.rotate = -90;
144-
} else {
145-
this.textAnchor = anchor === "right" ? "end" : "start";
146-
this.lineAnchor = cla;
147-
this.frameAnchor = `${cla}-${anchor}`;
148-
this.rotate = 0;
149-
}
150-
this.dy = cla === "top" ? 3 - marginTop : cla === "bottom" ? marginBottom - 3 : 0;
151-
this.dx = anchor === "right" ? clo : -clo;
152-
this.ariaLabel = `${k}-axis label`;
153-
return {
154-
facets: [[0]],
155-
channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}}
156-
};
157-
})
132+
? Object.assign(
133+
text(
134+
[],
135+
labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) {
136+
const scale = scales[k];
137+
const {marginTop, marginRight, marginBottom, marginLeft} = (k === "y" && dimensions.inset) || dimensions;
138+
const cla = labelAnchor ?? (scale.bandwidth ? "center" : "top");
139+
const clo = labelOffset ?? (anchor === "right" ? marginRight : marginLeft) - 3;
140+
if (cla === "center") {
141+
this.textAnchor = undefined; // middle
142+
this.lineAnchor = anchor === "right" ? "bottom" : "top";
143+
this.frameAnchor = anchor;
144+
this.rotate = -90;
145+
} else {
146+
this.textAnchor = anchor === "right" ? "end" : "start";
147+
this.lineAnchor = cla;
148+
this.frameAnchor = `${cla}-${anchor}`;
149+
this.rotate = 0;
150+
}
151+
this.dy = cla === "top" ? 3 - marginTop : cla === "bottom" ? marginBottom - 3 : 0;
152+
this.dx = anchor === "right" ? clo : -clo;
153+
this.ariaLabel = `${k}-axis label`;
154+
return {
155+
facets: [[0]],
156+
channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}}
157+
};
158+
})
159+
),
160+
{zoom: null}
158161
)
159162
: null
160163
);
@@ -230,29 +233,32 @@ function axisKx(
230233
})
231234
: null,
232235
!isNoneish(fill) && label !== null
233-
? text(
234-
[],
235-
labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) {
236-
const scale = scales[k];
237-
const {marginTop, marginRight, marginBottom, marginLeft} = (k === "x" && dimensions.inset) || dimensions;
238-
const cla = labelAnchor ?? (scale.bandwidth ? "center" : "right");
239-
const clo = labelOffset ?? (anchor === "top" ? marginTop : marginBottom) - 3;
240-
if (cla === "center") {
241-
this.frameAnchor = anchor;
242-
this.textAnchor = undefined; // middle
243-
} else {
244-
this.frameAnchor = `${anchor}-${cla}`;
245-
this.textAnchor = cla === "right" ? "end" : "start";
246-
}
247-
this.lineAnchor = anchor;
248-
this.dy = anchor === "top" ? -clo : clo;
249-
this.dx = cla === "right" ? marginRight - 3 : cla === "left" ? 3 - marginLeft : 0;
250-
this.ariaLabel = `${k}-axis label`;
251-
return {
252-
facets: [[0]],
253-
channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}}
254-
};
255-
})
236+
? Object.assign(
237+
text(
238+
[],
239+
labelOptions({fill, fillOpacity, ...options}, function (data, facets, channels, scales, dimensions) {
240+
const scale = scales[k];
241+
const {marginTop, marginRight, marginBottom, marginLeft} = (k === "x" && dimensions.inset) || dimensions;
242+
const cla = labelAnchor ?? (scale.bandwidth ? "center" : "right");
243+
const clo = labelOffset ?? (anchor === "top" ? marginTop : marginBottom) - 3;
244+
if (cla === "center") {
245+
this.frameAnchor = anchor;
246+
this.textAnchor = undefined; // middle
247+
} else {
248+
this.frameAnchor = `${anchor}-${cla}`;
249+
this.textAnchor = cla === "right" ? "end" : "start";
250+
}
251+
this.lineAnchor = anchor;
252+
this.dy = anchor === "top" ? -clo : clo;
253+
this.dx = cla === "right" ? marginRight - 3 : cla === "left" ? 3 - marginLeft : 0;
254+
this.ariaLabel = `${k}-axis label`;
255+
return {
256+
facets: [[0]],
257+
channels: {text: {value: [formatAxisLabel(k, scale, {anchor, label, labelAnchor: cla, labelArrow})]}}
258+
};
259+
})
260+
),
261+
{zoom: null}
256262
)
257263
: null
258264
);
@@ -507,8 +513,10 @@ function labelOptions(
507513

508514
function axisMark(mark, k, ariaLabel, data, options, initialize) {
509515
let channels;
516+
let u;
510517

511518
function axisInitializer(data, facets, _channels, scales, dimensions, context) {
519+
u = arguments;
512520
const initializeFacets = data == null && (k === "fx" || k === "fy");
513521
const {[k]: scale} = scales;
514522
if (!scale) throw new Error(`missing scale: ${k}`);
@@ -564,6 +572,24 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
564572
channels = {};
565573
}
566574
m.ariaLabel = ariaLabel;
575+
m.zoom = function (g, transform) {
576+
if (!(k === "x" || k === "y")) return g;
577+
const [, , , {[k]: scale}, dimensions, context] = [...u];
578+
if (scale.bandwidth) return g; // TODO ordinal scales?
579+
const scale2 = transform[k === "x" ? "rescaleX" : "rescaleY"](scale ?? scaleIdentity());
580+
const ticks = scale2.ticks();
581+
g.replaceWith(
582+
(g = m.render.call(
583+
m,
584+
ticks.map((d, i) => i),
585+
{[k]: scale2},
586+
{[k]: ticks.map(scale2), text: ticks},
587+
dimensions,
588+
context
589+
))
590+
);
591+
return g;
592+
};
567593
return m;
568594
}
569595

src/marks/dot.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {pathRound as path, symbolCircle} from "d3";
1+
import {pathRound as path, select, symbolCircle} from "d3";
22
import {create} from "../context.js";
33
import {negative, positive} from "../defined.js";
44
import {Mark} from "../mark.js";
@@ -128,6 +128,15 @@ export class Dot extends Mark {
128128
)
129129
.node();
130130
}
131+
zoom(node, transform, values) {
132+
const a = 1 / Math.sqrt(transform.k);
133+
select(node)
134+
.attr("transform", transform)
135+
.selectAll("circle")
136+
.attr("vector-effect", "non-scaling-stroke")
137+
.attr("r", values.r ? (i) => a * values.r[i] : this.r * a);
138+
return node;
139+
}
131140
}
132141

133142
export function dot(data, {x, y, ...options} = {}) {

src/plot.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {creator, select} from "d3";
1+
import {creator, select, zoom as zoomer} from "d3";
22
import {createChannel, inferChannelScale} from "./channel.js";
33
import {createContext} from "./context.js";
44
import {createDimensions} from "./dimensions.js";
@@ -20,7 +20,7 @@ import {initializer} from "./transforms/basic.js";
2020
import {consumeWarnings, warn} from "./warnings.js";
2121

2222
export function plot(options = {}) {
23-
const {facet, style, caption, ariaLabel, ariaDescription} = options;
23+
const {facet, style, caption, ariaLabel, ariaDescription, zoom} = options;
2424

2525
// className for inline styles
2626
const className = maybeClassName(options.className);
@@ -272,6 +272,7 @@ export function plot(options = {}) {
272272
.call(applyInlineStyles, style);
273273

274274
// Render marks.
275+
const nodesByMark = new Map();
275276
for (const mark of marks) {
276277
const {channels, values, facets: indexes} = stateByMark.get(mark);
277278

@@ -285,6 +286,7 @@ export function plot(options = {}) {
285286
}
286287
const node = mark.render(index, scales, values, superdimensions, context);
287288
if (node == null) continue;
289+
nodesByMark.set(mark, node);
288290
svg.appendChild(node);
289291
}
290292

@@ -337,6 +339,25 @@ export function plot(options = {}) {
337339
figure.scale = exposeScales(scaleDescriptors);
338340
figure.legend = exposeLegends(scaleDescriptors, context, options);
339341

342+
if (zoom || true) {
343+
select(svg).call(
344+
zoomer().on("start zoom end", ({transform}) => {
345+
// todo also opt-out when a scale is collapsed.
346+
if (scales.y?.bandwidth || zoom === "x") {
347+
transform.toString = () => `translate(${transform.x},${0})scale(${transform.k},1)`;
348+
transform.rescaleY = (y) => y;
349+
}
350+
if (scales.x?.bandwidth || zoom === "y") {
351+
transform.toString = () => `translate(${0},${transform.y})scale(1,${transform.k})`;
352+
transform.rescaleX = (x) => x;
353+
}
354+
for (const [mark, node] of nodesByMark) {
355+
if (mark.zoom != null) nodesByMark.set(mark, mark.zoom(node, transform, stateByMark.get(mark).values));
356+
}
357+
})
358+
);
359+
}
360+
340361
const w = consumeWarnings();
341362
if (w > 0) {
342363
select(svg)

0 commit comments

Comments
 (0)