Skip to content

Commit ceb54c3

Browse files
committed
zoom: first pass
1 parent fbe876e commit ceb54c3

File tree

4 files changed

+128
-53
lines changed

4 files changed

+128
-53
lines changed

src/mark.js

+19
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

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

523529
function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) {
524530
let channels;
531+
let u;
525532

526533
function axisInitializer(data, facets, _channels, scales, dimensions, context) {
534+
u = arguments;
527535
const initializeFacets = data == null && (k === "fx" || k === "fy");
528536
const {[k]: scale} = scales;
529537
if (!scale) throw new Error(`missing scale: ${k}`);
@@ -617,6 +625,24 @@ function axisMark(mark, k, anchor, ariaLabel, data, options, initialize) {
617625
}
618626
m.ariaLabel = ariaLabel;
619627
if (m.clip === undefined) m.clip = false; // don’t clip axes by default
628+
m.zoom = function (g, transform) {
629+
if (!(k === "x" || k === "y")) return g;
630+
const [, , , {[k]: scale}, dimensions, context] = [...u];
631+
if (scale.bandwidth) return g; // TODO ordinal scales?
632+
const scale2 = transform[k === "x" ? "rescaleX" : "rescaleY"](scale ?? scaleIdentity());
633+
const ticks = scale2.ticks();
634+
g.replaceWith(
635+
(g = m.render.call(
636+
m,
637+
ticks.map((d, i) => i),
638+
{[k]: scale2},
639+
{[k]: ticks.map(scale2), text: ticks},
640+
dimensions,
641+
context
642+
))
643+
);
644+
return g;
645+
};
620646
return m;
621647
}
622648

src/marks/dot.js

+10-1
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";
@@ -136,6 +136,15 @@ export class Dot extends Mark {
136136
)
137137
.node();
138138
}
139+
zoom(node, transform, values) {
140+
const a = 1 / Math.sqrt(transform.k);
141+
select(node)
142+
.attr("transform", transform)
143+
.selectAll("circle")
144+
.attr("vector-effect", "non-scaling-stroke")
145+
.attr("r", values.r ? (i) => a * values.r[i] : this.r * a);
146+
return node;
147+
}
139148
}
140149

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

src/plot.js

+23-2
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, title, subtitle, caption, ariaLabel, ariaDescription} = options;
23+
const {facet, style, title, subtitle, caption, ariaLabel, ariaDescription, zoom} = options;
2424

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

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

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

@@ -337,6 +339,25 @@ export function plot(options = {}) {
337339
figure.scale = exposeScales(scales.scales);
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)