From 11dadc505ef89a67922ad5bf95b5547a3a2bb39b Mon Sep 17 00:00:00 2001
From: Mike Bostock <mbostock@gmail.com>
Date: Sat, 23 Nov 2024 19:14:06 -0800
Subject: [PATCH] plot.rescale

---
 src/plot.js           | 23 +++++++++++++++++------
 test/plots/index.ts   |  1 +
 test/plots/rescale.ts | 18 ++++++++++++++++++
 3 files changed, 36 insertions(+), 6 deletions(-)
 create mode 100644 test/plots/rescale.ts

diff --git a/src/plot.js b/src/plot.js
index d92aca0587..491f477fde 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -139,7 +139,7 @@ export function plot(options = {}) {
     stateByMark.set(mark, {data, facets, channels});
   }
 
-  // Initalize the scales and dimensions.
+  // Initialize the scales and dimensions.
   const scaleDescriptors = createScales(addScaleChannels(channelsByScale, stateByMark, options), options);
   const dimensions = createDimensions(scaleDescriptors, marks, options);
 
@@ -159,6 +159,11 @@ export function plot(options = {}) {
   context.className = className;
   context.projection = createProjection(options, subdimensions);
 
+  // A path generator for marks that want to draw GeoJSON.
+  context.path = function () {
+    return geoPath(this.projection ?? xyProjection(scales));
+  };
+
   // Allows e.g. the axis mark to determine faceting lazily.
   context.filterFacets = (data, channels) => {
     return facetFilter(facets, {channels, groups: facetGroups(data, channels)});
@@ -236,11 +241,6 @@ export function plot(options = {}) {
     facetTranslate = facetTranslator(fx, fy, dimensions);
   }
 
-  // A path generator for marks that want to draw GeoJSON.
-  context.path = function () {
-    return geoPath(this.projection ?? xyProjection(scales));
-  };
-
   // Compute value objects, applying scales and projection as needed.
   for (const [mark, state] of stateByMark) {
     state.values = mark.scale(state.channels, scales, context);
@@ -357,6 +357,17 @@ export function plot(options = {}) {
       .text(`${w.toLocaleString("en-US")} warning${w === 1 ? "" : "s"}. Please check the console.`);
   }
 
+  figure.rescale = (rescales) => {
+    const reoptions = {...options, figure: false};
+    for (const key in rescales) {
+      if (!(key in scales.scales)) throw new Error(`missing scale: ${key}`);
+      reoptions[key] = {...scales.scales[key], ...rescales[key]};
+    }
+    const resvg = plot(reoptions);
+    while (svg.lastChild) svg.removeChild(svg.lastChild);
+    while (resvg.firstChild) svg.appendChild(resvg.firstChild);
+  };
+
   return figure;
 }
 
diff --git a/test/plots/index.ts b/test/plots/index.ts
index f54398dca7..8fe0cfa944 100644
--- a/test/plots/index.ts
+++ b/test/plots/index.ts
@@ -259,6 +259,7 @@ export * from "./raster-vapor.js";
 export * from "./raster-walmart.js";
 export * from "./rect-band.js";
 export * from "./reducer-scale-override.js";
+export * from "./rescale.js";
 export * from "./rounded-rect.js";
 export * from "./seattle-precipitation-density.js";
 export * from "./seattle-precipitation-rule.js";
diff --git a/test/plots/rescale.ts b/test/plots/rescale.ts
new file mode 100644
index 0000000000..127a541567
--- /dev/null
+++ b/test/plots/rescale.ts
@@ -0,0 +1,18 @@
+import * as Plot from "@observablehq/plot";
+import * as d3 from "d3";
+
+export async function rescaleZoom() {
+  const data = await d3.csv<any>("data/gistemp.csv", d3.autoType);
+  const plot = Plot.dot(data, {x: "Date", y: "Anomaly", stroke: "Anomaly"}).plot();
+  requestAnimationFrame(() => {
+    let frame: number;
+    (function tick(now) {
+      if (!plot.isConnected) return cancelAnimationFrame(frame);
+      const t = (Math.sin(now / 2000) + 1) / 2;
+      const [x1, x2] = plot.scale("x").domain;
+      plot.rescale({x: {domain: [+x1 + ((x2 - x1) / 2) * t, +x1 + ((x2 - x1) / 2) * (t + 1)]}});
+      frame = requestAnimationFrame(tick);
+    })(performance.now());
+  });
+  return plot;
+}