Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/heavy-terms-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": minor
---

Let minZoom be dynamically based on the size of the net
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"d3-scale": "4.0.2",
"elkjs": "0.11.0",
"fuzzysort": "3.1.0",
"lodash-es": "4.18.1",
"monaco-editor": "0.55.1",
"react-icons": "5.5.0",
"react-resizable-panels": "4.6.5",
Expand All @@ -66,6 +67,7 @@
"@testing-library/react": "16.3.2",
"@types/babel__standalone": "7.1.9",
"@types/d3-scale": "4.0.9",
"@types/lodash-es": "4.17.12",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260315.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { debounce, type DebouncedFunc } from "lodash-es";
import { useEffect, useMemo } from "react";

// debounces a function, holding the debounced function between re-renders
// when unmounting, we flush any unresolved debounced calls when updating the debounced
// function or unmounting to avoid resolving on stale data or refs
export function useDebounceCallback<
// eslint-disable-next-line typescript-eslint/no-explicit-any
T extends (...args: any) => ReturnType<T>,
>(func: T, delay = 500): DebouncedFunc<T> {
const debounced = useMemo(() => debounce(func, delay), [func, delay]);

useEffect(() => {
return () => {
debounced.flush();
};
}, [func, delay]);

return debounced;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useEffect, type RefObject } from "react";

// sets up a resize observer on an element
export function useResizeObserver(
elementRef: RefObject<HTMLElement | null>,
func: (entries?: ResizeObserverEntry[]) => void,
): void {
useEffect(() => {
if (elementRef.current) {
const resizeObserver = new ResizeObserver(func);

resizeObserver.observe(elementRef.current);

return () => {
resizeObserver.disconnect();
};
}
}, [elementRef, func]);
}
56 changes: 53 additions & 3 deletions libs/@hashintel/petrinaut/src/views/SDCPN/sdcpn-view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import "@xyflow/react/dist/style.css";

import { css } from "@hashintel/ds-helpers/css";
import { useResizeObserver } from "./hooks/util/use-resize-observer";
import { useDebounceCallback } from "./hooks/util/use-debounce-callback";
import type { Connection } from "@xyflow/react";
import { Background, ReactFlow, SelectionMode } from "@xyflow/react";
import { use, useEffect, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -43,6 +45,8 @@ const REACTFLOW_EDGE_TYPES = {
default: Arc,
};

const ZOOM_PADDING = 0.5;

const canvasContainerStyle = css({
width: "[100%]",
height: "[100%]",
Expand All @@ -69,6 +73,9 @@ export const SDCPNView: React.FC<{
() => (compactNodes ? COMPACT_NODE_TYPES : CLASSIC_NODE_TYPES),
[compactNodes],
);
// min-zoom 0 allows a user to zoom out infinitely. We later constrain this to be slightly larger than the nodes present
// in the net, but default to 0 until we can measure the viewport height
const [minZoom, setMinZoom] = useState(0);

// SDCPN store
const { petriNetId } = use(SDCPNContext);
Expand All @@ -91,12 +98,47 @@ export const SDCPNView: React.FC<{
// Center viewport on SDCPN load
useEffect(() => {
void reactFlowInstance?.fitView({
padding: 0.4,
minZoom: 0.4,
padding: ZOOM_PADDING,
minZoom: minZoom,
maxZoom: 1.1,
});
}, [reactFlowInstance, petriNetId]);

// 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 = useDebounceCallback(
(
instance: PetrinautReactFlowInstance | null,
canvasEl: React.RefObject<HTMLDivElement | null>,
) => {
const nodesSize = instance?.getNodesBounds(instance.getNodes());
const viewportSize = canvasEl.current?.getBoundingClientRect();

if (viewportSize && nodesSize) {
// Specifically check that the height and width are not 0. If the net is empty/size 0, use a default minZoom of 0.5
// otherwise, set the minZoom to the size of the net with some extra padding
const newZoom =
nodesSize.height && nodesSize.width
? Math.min(
viewportSize.height / nodesSize.height,
viewportSize.width / nodesSize.width,
) * ZOOM_PADDING
: 0.5;

// Don't reduce the zoom level below the users current zoom
const currentZoom = instance?.getViewport().zoom;
const safeZoom = currentZoom ? Math.min(currentZoom, newZoom) : newZoom;

setMinZoom(safeZoom);
}
},
100,
);

useResizeObserver(canvasContainer, () => {
fitZoomToNodes(reactFlowInstance, canvasContainer);
});

const isReadonly = useIsReadOnly();

function isValidConnection(connection: Connection) {
Expand Down Expand Up @@ -141,6 +183,7 @@ export const SDCPNView: React.FC<{

function onInit(instance: PetrinautReactFlowInstance) {
setReactFlowInstance(instance);
fitZoomToNodes(instance, canvasContainer);
}

// Shared function to create a node at a given position
Expand Down Expand Up @@ -314,14 +357,20 @@ export const SDCPNView: React.FC<{
edges={arcs}
nodeTypes={nodeTypes}
edgeTypes={REACTFLOW_EDGE_TYPES}
onNodesChange={applyNodeChanges}
onNodesChange={(n) => {
applyNodeChanges(n);
fitZoomToNodes(reactFlowInstance, canvasContainer);
}}
onEdgesChange={applyNodeChanges}
onConnect={isReadonly ? undefined : onConnect}
onInit={onInit}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
onDrop={isReadonly ? undefined : onDrop}
onDragOver={isReadonly ? undefined : onDragOver}
onViewportChange={() => {
fitZoomToNodes(reactFlowInstance, canvasContainer);
}}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
proOptions={{ hideAttribution: true }}
panOnDrag={isPanMode ? true : isAddMode ? false : [1, 2]}
Expand All @@ -337,6 +386,7 @@ export const SDCPNView: React.FC<{
deleteKeyCode={null}
panOnScroll={false}
zoomOnScroll
minZoom={minZoom}
>
<Background gap={SNAP_GRID_SIZE} size={1} />
{showMinimap && <MiniMap pannable zoomable />}
Expand Down
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7807,6 +7807,7 @@ __metadata:
"@testing-library/react": "npm:16.3.2"
"@types/babel__standalone": "npm:7.1.9"
"@types/d3-scale": "npm:4.0.9"
"@types/lodash-es": "npm:4.17.12"
"@types/react": "npm:19.2.7"
"@types/react-dom": "npm:19.2.3"
"@typescript/native-preview": "npm:7.0.0-dev.20260315.1"
Expand All @@ -7817,6 +7818,7 @@ __metadata:
elkjs: "npm:0.11.0"
fuzzysort: "npm:3.1.0"
jsdom: "npm:24.1.3"
lodash-es: "npm:4.18.1"
monaco-editor: "npm:0.55.1"
oxlint: "npm:1.55.0"
oxlint-tsgolint: "npm:0.17.0"
Expand Down
Loading