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 option #2083

Draft
wants to merge 1 commit 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
3 changes: 3 additions & 0 deletions src/plot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ export interface PlotOptions extends ScaleDefaults {
*/
projection?: ProjectionOptions | ProjectionName | ProjectionFactory | ProjectionImplementation | null;

/** TODO */
zoom?: boolean;

/**
* Options for the horizontal facet position *fx* scale. If present, the *fx*
* scale is always a *band* scale.
Expand Down
66 changes: 64 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 Zoom} 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 {zoom, facet, style, title, subtitle, caption, ariaLabel, ariaDescription} = options;

// className for inline styles
const className = maybeClassName(options.className);
Expand Down Expand Up @@ -287,6 +287,7 @@ export function plot(options = {}) {
const node = mark.render(index, scales, values, superdimensions, context);
if (node == null) continue;
svg.appendChild(node);
stateByMark.get(mark).node = node; // TODO
}

// Render a faceted mark.
Expand Down Expand Up @@ -321,6 +322,67 @@ export function plot(options = {}) {
}
}

// Apply the zoom behavior.
// TODO Keyboard shortcuts for zooming (+-) and panning (↑↓←→).
// TODO Some affordance for resetting to the “home” view.
// TODO Conditional x and y (scale may not exist, or may not be zoomable).
// TODO Constrained zoom (both in translate and scale extent).
// TODO Support mark initializers (axis ticks).
// TODO Support transforms (bin transform)?
// TODO Support faceted marks.
// TODO Handle marks with conditional output.
if (zoom) {
const zoomed = ({transform}) => {
const {x, y} = scaleDescriptors;

// Compute the zoomed x and y domains.
const xDomain = Array.from(x.range, (v) => x.scale.invert(transform.invertX(v)));
const yDomain = Array.from(y.range, (v) => y.scale.invert(transform.invertY(v)));

// Compute the zoomed x and y scales.
const zoomX = {...x, domain: xDomain, scale: x.scale.copy().domain(xDomain)};
const zoomY = {...y, domain: yDomain, scale: y.scale.copy().domain(yDomain)};
const zoomScales = createScaleFunctions({x: zoomX, y: zoomY});

// Merge the zoomed scales with the other scales.
const mergeScales = {...scales, ...zoomScales, scales: {...scales.scales, ...zoomScales.scales}};

// Re-render each mark.
for (const [mark, state] of stateByMark) {
const {channels, values, facets: indexes} = state;

// Extract the zoomed position channels.
// TODO Handle mark initializers (e.g., axes).
const zoomChannels = {};
for (const key in channels) {
const channel = channels[key];
if (channel.scale === "x" || channel.scale === "y") {
zoomChannels[key] = channel;
}
}

// Compute the zoomed position channel values.
const zoomValues = mark.scale(zoomChannels, zoomScales, context);
const mergeValues = {...values, ...zoomValues, channels: {...values.channels, ...zoomValues.channels}};

// Replace the mark’s previously-rendered output.
if (facets === undefined || mark.facet === "super") {
let index = null;
if (indexes) {
index = indexes[0];
index = mark.filter(index, channels, mergeValues);
if (index.length === 0) continue;
}
const node = mark.render(index, mergeScales, mergeValues, superdimensions, context);
if (node == null) continue;
state.node.replaceWith(node);
state.node = node;
}
}
};
select(svg).call(Zoom().on("zoom", zoomed));
}

// Wrap the plot in a figure, if needed.
const legends = createLegends(scaleDescriptors, context, options);
const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options;
Expand Down
1 change: 1 addition & 0 deletions test/plots/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,4 @@ export * from "./yearly-requests-line.js";
export * from "./yearly-requests.js";
export * from "./young-adults.js";
export * from "./zero.js";
export * from "./zoom.js";
7 changes: 7 additions & 0 deletions test/plots/zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";

export async function zoomDot() {
const penguins = await d3.csv<any>("data/penguins.csv", d3.autoType);
return Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}).plot({grid: true, zoom: true});
}
Loading