Skip to content

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

Open
alex-e-leon wants to merge 4 commits intomainfrom
FE-528-fit-petrinaut-zoom-out-to-net
Open

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

Conversation

@alex-e-leon
Copy link
Copy Markdown

🌟 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 7, 2026 5:56pm
petrinaut Ready Ready Preview, Comment Apr 7, 2026 5:56pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign Ignored Ignored Preview Apr 7, 2026 5:56pm
hashdotdesign-tokens Ignored Ignored Preview Apr 7, 2026 5:56pm

@cursor
Copy link
Copy Markdown

cursor bot commented Apr 7, 2026

PR Summary

Medium Risk
Changes core canvas zoom constraints and adds resize/viewport listeners, which could impact editor UX/performance or cause unexpected zoom limits in edge cases (empty nets, rapid updates). Dependency additions are low risk but affect bundle size.

Overview
Makes Petrinaut’s SDCPN canvas min zoom dynamic instead of fixed, so users can’t zoom out beyond what’s needed to keep the current net in view.

SDCPNView now computes minZoom from the nodes’ bounding box vs. the viewport (debounced), updates it on init, node changes, viewport changes, and container resizes (via ResizeObserver), and feeds it into both fitView and the ReactFlow minZoom prop.

Adds lodash-es (and typings) plus a changeset bumping @hashintel/petrinaut as a minor release.

Reviewed by Cursor Bugbot for commit edc2ecc. 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

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@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
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 2 potential issues.

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 922e1fb. Configure here.

Comment on lines +141 to +153
useEffect(() => {
if (canvasContainer.current) {
const resizeObserver = new ResizeObserver(() => {
fitZoomToNodes(reactFlowInstance, canvasContainer, setMinZoom);
});

resizeObserver.observe(canvasContainer.current);

return () => {
resizeObserver.disconnect();
};
}
}, [canvasContainer, reactFlowInstance]);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we can use a library or at least put this hook into its own file: it would be easier to read this component. (Not that it's particularly easy to read at the moment)

It is equivalent to https://usehooks-ts.com/react-hook/use-resize-observer

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ok sorry I had not read properly.

It is not equivalent, but IMO it would be better to have a hook returning the dimensions as state of the React component.

e.g.

const { width, height } = useResizeObserver(canvasContainer.current)

setMinZoom: (minZoom: number) => void,
) => {
const nodesSize = instance?.getNodesBounds(instance.getNodes());
const viewportSize = canvasContainer.current?.getBoundingClientRect();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

getBoundingClientRect is called again here, and this method triggers a layout/reflow, so is really expensive.

It would be better to have it only called in the ResizeObserver (or not even needed actually, given ResizeObserver should return that already).


// This sets the min zoom (ie the max you can zoom out to) to be slightly larger than the total size of the current net.
// We also avoid shrinking the zoom to be lower than the current zoom level to avoid changing the zoom without user input
const fitZoomToNodes = useMemo(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why. useMemo and not useCallback
(or even remove it completely given we should have React Compiler)

Comment on lines +109 to +110
() =>
debounce(
Copy link
Copy Markdown
Collaborator

@kube kube Apr 7, 2026

Choose a reason for hiding this comment

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

canvasContainer: React.RefObject<HTMLDivElement | null>,
setMinZoom: (minZoom: number) => void,
) => {
const nodesSize = instance?.getNodesBounds(instance.getNodes());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same as for getBoundingClientRect, this is I suppose expensive, and would be better derived directly in the body of the component.

Like:

const nodesSize = instance?.getNodesBounds(instance.getNodes());

Though, this would not be reactive.

So maybe we can just compute that ourselves without relying on React Flow. (By just creating a function that would give us that just using SDCPNContext)

@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
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
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)

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