Skip to content

FE-528: Fit Petrinaut zoom out to size of net#8611

Merged
alex-e-leon merged 7 commits intomainfrom
FE-528-fit-petrinaut-zoom-out-to-net
Apr 8, 2026
Merged

FE-528: Fit Petrinaut zoom out to size of net#8611
alex-e-leon merged 7 commits intomainfrom
FE-528-fit-petrinaut-zoom-out-to-net

Conversation

@alex-e-leon
Copy link
Copy Markdown
Contributor

🌟 What is the purpose of this PR?

This PR introduces improves the way that zoom is handled in petrinaut. Rather than a fixed value, which might be too small to view large nets, or an infinite zoom out, which make it easy to lose your net if you zoom out too far, I'm introducing a dynamic zoom that maxes out at a level just large enough to view the entire net, but no larger.

There's a bit of extra logic to handle debouncing, screen resizing, and a seamless UX so that users don't notice when we change the maximum zoom underneath them.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

  • I am unsure / need advice

❓ How to test this?

  1. Checkout the branch + run petrinaut
  2. Zoom out to max
  3. Change the size of the net
  4. Zoom out/in depending on whether you increased or decreased the net size
  5. Assert that the max-zoom level changed

@alex-e-leon alex-e-leon self-assigned this Apr 7, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Apr 8, 2026 2:51pm
petrinaut Ready Ready Preview, Comment Apr 8, 2026 2:51pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign Ignored Ignored Preview Apr 8, 2026 2:51pm
hashdotdesign-tokens Ignored Ignored Preview Apr 8, 2026 2:51pm

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 7, 2026

PR Summary

Medium Risk
Changes ReactFlow viewport constraints by dynamically recalculating minZoom based on node bounds and container size, which can affect navigation UX and initial fit behavior. Adds new debounced/resize-observer hooks and a lodash-es dependency that could introduce subtle timing or bundling issues.

Overview
Petrinaut now dynamically constrains how far users can zoom out by computing minZoom from the current net’s bounds relative to the canvas size, instead of using a fixed value.

This introduces useDebounceCallback (debounced recalculation with flush-on-cleanup) and useResizeObserver to recompute zoom limits on node changes, viewport changes, initialization, and container resizes, and wires the result into ReactFlow via minZoom and updated fitView options.

Reviewed by Cursor Bugbot for commit 4e6c960. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions github-actions bot added area/deps Relates to third-party dependencies (area) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team labels Apr 7, 2026
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 7, 2026

CLA assistant check
All committers have signed the CLA.

@vilkinsons vilkinsons changed the title FE-528 Fit Petrinaut zoom out to size of net FE-528: Fit Petrinaut zoom out to size of net Apr 7, 2026
@augmentcode
Copy link
Copy Markdown

augmentcode bot commented Apr 7, 2026

🤖 Augment PR Summary

Summary: Improves Petrinaut zoom-out behavior by dynamically constraining the minimum zoom based on the current net bounds and viewport size.

Changes:

  • Introduce a debounced bounds-to-viewport calculation (`fitZoomToNodes`) to derive a net-fitting min zoom with padding
  • Track `minZoom` in `SDCPNView` state and wire it into `fitView` and the `ReactFlow` `minZoom` prop
  • Recompute min zoom on init, node changes, viewport changes, and canvas container resizes via `ResizeObserver`
  • Add `lodash-es` (+ types) to support debouncing

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 3 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Min zoom ratchets up, permanently preventing zoom-out
    • The min-zoom calculation now derives from previous minZoom state (with a one-time initialization cap) so zooming in no longer ratchets the floor upward and users can zoom back out.

Create PR

Or push these changes by commenting:

@cursor push dd118ea3c1
Preview (dd118ea3c1)
diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx
--- a/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx
+++ b/libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx
@@ -51,7 +51,7 @@
   (
     instance: PetrinautReactFlowInstance | null,
     canvasContainer: React.RefObject<HTMLDivElement | null>,
-    setMinZoom: (minZoom: number) => void,
+    setMinZoom: React.Dispatch<React.SetStateAction<number>>,
   ) => {
     const minMaxCoords = instance?.getNodesBounds(instance.getNodes());
     const viewportSize = canvasContainer.current?.getBoundingClientRect();
@@ -62,12 +62,17 @@
         viewportSize.width / minMaxCoords.width,
       );
       const currentZoom = instance?.getViewport().zoom;
-      // Don't reduce the zoom level below the users current zoom
-      const safeZoom = currentZoom
-        ? Math.min(currentZoom, newZoom * ZOOM_PADDING)
-        : newZoom * ZOOM_PADDING;
+      setMinZoom((currentMinZoom) => {
+        // Don't increase min zoom beyond the currently configured minimum.
+        // On first initialization (min zoom still 0), cap to the user's current zoom.
+        if (currentMinZoom === 0) {
+          return currentZoom
+            ? Math.min(currentZoom, newZoom * ZOOM_PADDING)
+            : newZoom * ZOOM_PADDING;
+        }
 
-      setMinZoom(safeZoom);
+        return Math.min(currentMinZoom, newZoom * ZOOM_PADDING);
+      });
     }
   },
   100,

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@graphite-app graphite-app bot requested review from a team April 7, 2026 17:34
@kube
Copy link
Copy Markdown
Collaborator

kube commented Apr 7, 2026

Ideally we want to define everything as a graph of derived states, and not rely on ReactFlow for calculations:

const RESIZE_DEBOUNCE = 200;

const canvasDimensions = useDebounceValue(useResizeObserver(canvasContainer.current), RESIZE_DEBOUNCE)
const petrinetDimensions = computeNetDimensions(sdcpn)
const minZoom = computeMinZoom(sdcpn, canvasDimensions)

@alex-e-leon
Copy link
Copy Markdown
Contributor Author

Ideally we want to define everything as a graph of derived states, and not rely on ReactFlow for calculations:

const RESIZE_DEBOUNCE = 200;

const canvasDimensions = useDebounceValue(useResizeObserver(canvasContainer.current), RESIZE_DEBOUNCE)
const petrinetDimensions = computeNetDimensions(sdcpn)
const minZoom = computeMinZoom(sdcpn, canvasDimensions)

There are actually 3 seperate values we are using to define the new minzoom value: the size of the viewport, the size of the net, and the users current zoom value.

The biggest performance hits are during the resize and zoom events, where these values change continuously and if not debounced will trigger large amounts of re-renders.

That means that if we split the state into its corresponding parts: a) the zoom and viewport size values will not be perfectly accurate if used by other functions as they will be debounced. This could be confusing. And b) because we are debouncing both values seperately, we will trigger double the rerenders, so its going to be less performant

@alex-e-leon
Copy link
Copy Markdown
Contributor Author

Ideally we want to define everything as a graph of derived states, and not rely on ReactFlow for calculations:

const RESIZE_DEBOUNCE = 200;

const canvasDimensions = useDebounceValue(useResizeObserver(canvasContainer.current), RESIZE_DEBOUNCE)
const petrinetDimensions = computeNetDimensions(sdcpn)
const minZoom = computeMinZoom(sdcpn, canvasDimensions)

There are actually 3 seperate values we are using to define the new minzoom value: the size of the viewport, the size of the net, and the users current zoom value.

The biggest performance hits are during the resize and zoom events, where these values change continuously and if not debounced will trigger large amounts of re-renders.

That means that if we split the state into its corresponding parts: a) the zoom and viewport size values will not be perfectly accurate if used by other functions as they will be debounced. This could be confusing. And b) because we are debouncing both values seperately, we will trigger double the rerenders, so its going to be less performant

That said, it may be still be better to do it your way vs adding a second observable if we need to reuse the viewport values for some other function so not totally against it.

@kube
Copy link
Copy Markdown
Collaborator

kube commented Apr 7, 2026

There are actually 3 seperate values we are using to define the new minzoom value: the size of the viewport, the size of the net, and the users current zoom value.

The biggest performance hits are during the resize and zoom events, where these values change continuously and if not debounced will trigger large amounts of re-renders.

That means that if we split the state into its corresponding parts: a) the zoom and viewport size values will not be perfectly accurate if used by other functions as they will be debounced. This could be confusing. And b) because we are debouncing both values seperately, we will trigger double the rerenders, so its going to be less performant

The only debounced value here would be the canvas dimensions (trigger by resizing).

Change in user zoom should not trigger re-calculation of canvasDimensions and petrinetDimensions:

const RESIZE_DEBOUNCE = 200;

const canvasDimensions = useDebounceValue(useResizeObserver(canvasContainer.current), RESIZE_DEBOUNCE)
const petrinetDimensions = computeNetDimensions(sdcpn)
const minZoom = computeMinZoom(sdcpn, canvasDimensions)

const actualMinZoom = Math.min(userZoom, minZoom)

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bb7f3a7. Configure here.

@alex-e-leon alex-e-leon added this pull request to the merge queue Apr 8, 2026
Merged via the queue into main with commit 21327f5 Apr 8, 2026
44 checks passed
@alex-e-leon alex-e-leon deleted the FE-528-fit-petrinaut-zoom-out-to-net branch April 8, 2026 15:36
@hashdotai hashdotai mentioned this pull request Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/deps Relates to third-party dependencies (area) area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

3 participants