Skip to content

Commit

Permalink
zoom: first pass
Browse files Browse the repository at this point in the history
  • Loading branch information
Fil committed Jul 7, 2023
1 parent 7b92d54 commit a220288
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 53 deletions.
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 {extent, format, timeFormat, utcFormat} from "d3";
import {extent, format, scaleIdentity, timeFormat, utcFormat} from "d3";
import {formatDefault} from "../format.js";
import {marks} from "../mark.js";
import {radians} from "../math.js";
Expand Down Expand Up @@ -129,32 +129,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 @@ -230,29 +233,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 @@ -507,8 +513,10 @@ function labelOptions(

function axisMark(mark, k, 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 @@ -564,6 +572,24 @@ function axisMark(mark, k, ariaLabel, data, options, initialize) {
channels = {};
}
m.ariaLabel = ariaLabel;
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 @@ -128,6 +128,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, caption, ariaLabel, ariaDescription} = options;
const {facet, style, caption, ariaLabel, ariaDescription, zoom} = options;

// className for inline styles
const className = maybeClassName(options.className);
Expand Down Expand Up @@ -272,6 +272,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 @@ -285,6 +286,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(scaleDescriptors);
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

0 comments on commit a220288

Please sign in to comment.