Skip to content

Commit

Permalink
Dynamic zoom (#2230)
Browse files Browse the repository at this point in the history
* workflow-diagram: work out whether we need to fit before laying out for a trigger

* workflow-diagram: tidy implementation

* workflow-diagram: scale visible rect to allow a bleed

* workflow-diagram: remove nasty autofit toolbar

* workflow-diagram: use real dom size and also fit when a placeholder is removed

* workflow-diagram: improve autofit behaviour

When autofitting, we have to use the OLD as well as NEW positions to decide what to fit

* workflow-diagram: fix the maths and tidy up

* workflow-diagram: fit to visible on resize

* changelog

* workflow-diagram: remove console logs

* workflow-diagram: remove duplicate prop

* workflow-diagram: tidy comments

* remove one more comment
  • Loading branch information
josephjclark authored Jul 2, 2024
1 parent 3848916 commit 6e25aa3
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 22 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ and this project adheres to

### Changed

- In the workflow diagram, smartly update the view when adding new nodes
[#2174](https://github.com/OpenFn/lightning/issues/2174)
- In the workflow diagram, remove the "autofit" toggle in the control bar

### Fixed

## [v2.7.1] - 2024-07-01
Expand Down
60 changes: 43 additions & 17 deletions assets/js/workflow-diagram/WorkflowDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import ReactFlow, {
ReactFlowInstance,
ReactFlowProvider,
applyNodeChanges,
getRectOfNodes,
Rect,
} from 'reactflow';
import { useStore, StoreApi } from 'zustand';
import { shallow } from 'zustand/shallow';
Expand All @@ -24,6 +26,7 @@ import shouldLayout from './util/should-layout';

import type { WorkflowState } from '../workflow-editor/store';
import type { Flow, Positions } from './types';
import { getVisibleRect, isPointInRect } from './util/viewport';

type WorkflowDiagramProps = {
selection: string | null;
Expand All @@ -43,8 +46,6 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(

const [model, setModel] = useState<Flow.Model>({ nodes: [], edges: [] });

const [autofit, setAutofit] = useState<boolean>(true);

const updateSelection = useCallback(
(id?: string | null) => {
id = id || null;
Expand Down Expand Up @@ -102,7 +103,11 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(

if (layoutId) {
chartCache.current.lastLayout = layoutId;
layout(newModel, setModel, flow, { duration: 300, autofit }).then(
const viewBounds = {
width: ref?.clientWidth ?? 0,
height: ref?.clientHeight ?? 0,
};
layout(newModel, setModel, flow, viewBounds, { duration: 300 }).then(
positions => {
// Note we don't update positions until the animation has finished
chartCache.current.positions = positions;
Expand All @@ -121,7 +126,7 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(
} else {
chartCache.current.positions = {};
}
}, [workflow, flow, placeholders]);
}, [workflow, flow, placeholders, ref]);

useEffect(() => {
const updatedModel = updateSelectionStyles(model, selection);
Expand Down Expand Up @@ -167,13 +172,43 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(
[updateSelection]
);

// Trigger a fit when the parent div changes size
// Trigger a fit to bounds when the parent div changes size
// To keep the chart more stable, try and take a snapshot of the target bounds
// when a new resize starts
// This will be imperfect but stops the user completely losing context
useEffect(() => {
if (flow && ref) {
let isFirstCallback = true;

let cachedTargetBounds: Rect;
let cacheTimeout: any;

const throttledResize = throttle(() => {
flow.fitView({ duration: FIT_DURATION, padding: FIT_PADDING });
clearTimeout(cacheTimeout);

// After 3 seconds, clear the timeout and take a new cache snapshot
cacheTimeout = setTimeout(() => {
cachedTargetBounds = null;
}, 3000);

if (!cachedTargetBounds) {
// Take a snapshot of what bounds to try and maintain throughout the resize
const viewBounds = {
width: ref?.clientWidth ?? 0,
height: ref?.clientHeight ?? 0,
};
const rect = getVisibleRect(flow.getViewport(), viewBounds, 1);
const visible = model.nodes.filter(n =>
isPointInRect(n.position, rect)
);
cachedTargetBounds = getRectOfNodes(visible);
}

// Run an animated fit
flow.fitBounds(cachedTargetBounds, {
duration: FIT_DURATION,
padding: FIT_PADDING,
});
}, FIT_DURATION * 2);

const resizeOb = new ResizeObserver(function (entries) {
Expand All @@ -190,7 +225,7 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(
resizeOb.unobserve(ref);
};
}
}, [flow, ref]);
}, [flow, ref, model]);

const connectHandlers = useConnect(model, setModel, store);

Expand All @@ -214,16 +249,7 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(
minZoom={0.2}
{...connectHandlers}
>
<Controls showInteractive={false} position="bottom-left">
<ControlButton
onClick={() => {
setAutofit(!autofit);
}}
title="Automatically fit view"
>
<ViewfinderCircleIcon style={{ opacity: autofit ? 1 : 0.4 }} />
</ControlButton>
</Controls>
<Controls showInteractive={false} position="bottom-left" />
</ReactFlow>
</ReactFlowProvider>
);
Expand Down
95 changes: 90 additions & 5 deletions assets/js/workflow-diagram/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@ import { getRectOfNodes, ReactFlowInstance } from 'reactflow';

import { FIT_PADDING } from './constants';
import { Flow, Positions } from './types';
import { getVisibleRect, isPointInRect } from './util/viewport';

export type LayoutOpts = { duration: number | false; autofit: boolean };
export type LayoutOpts = {
duration?: number | false;
autofit?: boolean | Flow.Node[];
};

const calculateLayout = async (
model: Flow.Model,
update: (newModel: Flow.Model) => any,
flow: ReactFlowInstance,
options: LayoutOuts = {}
viewBounds: { width: number; height: number },
options: Omit<LayoutOpts, 'autofit'> = {}
): Promise<Positions> => {
const { nodes, edges } = model;
const { duration } = options;

// Before we layout, work out whether there are any new unpositioned placeholders
// @ts-ignore _default is a temporary flag added by us
const newPlaceholders = model.nodes.filter(n => n.position?._default);

const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: 'TB',
Expand Down Expand Up @@ -47,9 +56,78 @@ const calculateLayout = async (

const hasOldPositions = nodes.find(n => n.position);

let autofit: LayoutOpts['autofit'] = false;
let doFit: boolean = false;
const fitTargets: Flow.Node[] = [];

if (hasOldPositions) {
const oldPositions = nodes.reduce((obj, next) => {
obj[next.id] = next.position;
return obj;
}, {} as Positions);

// When updating the layout, we should try and fit to the currently visible nodes
// This usually just occurs when adding or removing placeholder nodes

// First work out the size of the current viewpoint in canvas coordinates
if (newPlaceholders.length) {
const rect = getVisibleRect(flow.getViewport(), viewBounds, 0.9);
// Now work out the visible nodes, paying special attention to the placeholder
//
for (const id in finalPositions) {
// Check the node's old position to see if it was visible before the layout
// if it's a new node, take the new position
const pos = oldPositions[id] || finalPositions;
const isInside = isPointInRect(pos, rect);
const node = newModel.nodes.find(n => n.id === id)!;

if (isInside) {
// if the node was previously visible, add it to the fit list
fitTargets.push(node);
// but also, if the NEW position is NOT visible, we need to force a layout
if (!doFit && !isPointInRect(finalPositions[id], rect)) {
doFit = true;
}
} else if (node?.type === 'placeholder') {
// If the placeholder is NOT visible within the bounds,
// include it in the set of visible nodes and force a fit
doFit = true;
fitTargets.push({
...node,
// cheat on the size so we get a better fit
height: 100,
width: 100,
});
}
}
} else {
// otherwise, if running a layout, fit to the currently visible nodes
// this usually means we've removed a placeholder and lets us tidy up
doFit = true;
const rect = getVisibleRect(flow.getViewport(), viewBounds, 1.1);
for (const id in finalPositions) {
// again, use the OLD position to work out visibility
const pos = oldPositions[id] || finalPositions;
const isInside = isPointInRect(pos, rect);
if (isInside) {
const node = newModel.nodes.find(n => n.id === id)!;
fitTargets.push(node);
}
}
}

// Useful debugging
//console.log(fitTargets.map(n => n.data?.name ?? n.type));
}

// If we need to run a fit, save the set of visible nodes as the fit target
if (doFit) {
autofit = fitTargets;
}

// If the old model had no positions, this is a first load and we should not animate
if (hasOldPositions && duration) {
await animate(model, newModel, update, flow, options);
await animate(model, newModel, update, flow, { duration, autofit });
} else {
update(newModel);
}
Expand Down Expand Up @@ -102,9 +180,16 @@ export const animate = (

if (isFirst) {
// Synchronise a fit to the final position with the same duration
const bounds = getRectOfNodes(to.nodes);
let fitTarget = to.nodes;
if (typeof autofit !== 'boolean') {
fitTarget = autofit;
}
const bounds = getRectOfNodes(fitTarget);
if (autofit) {
flowInstance.fitBounds(bounds, { duration, padding: FIT_PADDING });
flowInstance.fitBounds(bounds, {
duration: typeof duration === 'number' ? duration : 0,
padding: FIT_PADDING,
});
}
isFirst = false;
}
Expand Down
3 changes: 3 additions & 0 deletions assets/js/workflow-diagram/usePlaceholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const create = (parentNode: Flow.Node) => {
id: targetId,
type: 'placeholder',
position: {
// mark this as as default position
// @ts-ignore _default is a temporary flag added by us
_default: true,
// Offset the position of the placeholder to be more pleasing during animation
x: parentNode.position.x,
y: parentNode.position.y + 100,
Expand Down
37 changes: 37 additions & 0 deletions assets/js/workflow-diagram/util/viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Rect, XYPosition, Viewport } from 'reactflow';

type ViewBounds = {
width: number;
height: number;
};

export const getVisibleRect = (
viewport: Viewport,
viewBounds: ViewBounds,
scale = 1
) => {
// Invert the zoom so that low zooms INCREASE the bouds size
const zoom = 1 / viewport.zoom;

// Also invert the viewport x and y positions
const x = -viewport.x + (1 - scale) * viewport.x;
const y = -viewport.y + (1 - scale) * viewport.y;

// Return the projected visible rect
return {
x: x * zoom,
width: viewBounds.width * scale * zoom,
y: y * zoom,
height: viewBounds.height * scale * zoom,
};
};

// This returns true if the point at pos fits anywhere inside rect
export const isPointInRect = (pos: XYPosition, rect: Rect) => {
return (
pos.x >= rect.x &&
pos.x <= rect.x + rect.width &&
pos.y >= rect.y &&
pos.y <= rect.y + rect.height
);
};

0 comments on commit 6e25aa3

Please sign in to comment.