diff --git a/src/plot.d.ts b/src/plot.d.ts index 05fc238dc5..e0328e8da5 100644 --- a/src/plot.d.ts +++ b/src/plot.d.ts @@ -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. diff --git a/src/plot.js b/src/plot.js index a091d8d8a8..34a44617d0 100644 --- a/src/plot.js +++ b/src/plot.js @@ -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"; @@ -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); @@ -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. @@ -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; diff --git a/test/plots/index.ts b/test/plots/index.ts index 907109e590..52ccb4ca20 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -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"; diff --git a/test/plots/zoom.ts b/test/plots/zoom.ts new file mode 100644 index 0000000000..e38025e184 --- /dev/null +++ b/test/plots/zoom.ts @@ -0,0 +1,7 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; + +export async function zoomDot() { + const penguins = await d3.csv("data/penguins.csv", d3.autoType); + return Plot.dot(penguins, {x: "culmen_length_mm", y: "culmen_depth_mm"}).plot({grid: true, zoom: true}); +}