diff --git a/packages/app/src/components/DraggableNode.tsx b/packages/app/src/components/DraggableNode.tsx index a3435805d..1529bd26e 100644 --- a/packages/app/src/components/DraggableNode.tsx +++ b/packages/app/src/components/DraggableNode.tsx @@ -16,6 +16,7 @@ import { type ProcessDataForNode } from '../state/dataFlow'; import { type DraggingWireDef } from '../state/graphBuilder'; interface DraggableNodeProps { + renderSkeleton?: boolean; heightCache: HeightCache; node: ChartNode; connections?: NodeConnection[]; @@ -32,6 +33,7 @@ interface DraggableNodeProps { processPage: number | 'latest'; draggingWire?: DraggingWireDef; isZoomedOut: boolean; + isReallyZoomedOut: boolean; isPinned: boolean; onWireEndDrag?: (event: MouseEvent, endNodeId: NodeId, endPortId: PortId) => void; onNodeSelected?: (node: ChartNode, multi: boolean) => void; @@ -66,7 +68,9 @@ export const DraggableNode: FC = ({ processPage, draggingWire, isZoomedOut, + isReallyZoomedOut, isPinned, + renderSkeleton, onWireStartDrag, onWireEndDrag, onNodeSelected, @@ -97,7 +101,9 @@ export const DraggableNode: FC = ({ processPage={processPage} draggingWire={draggingWire} isZoomedOut={isZoomedOut} + isReallyZoomedOut={isReallyZoomedOut} isPinned={isPinned} + renderSkeleton={renderSkeleton} onWireEndDrag={onWireEndDrag} onWireStartDrag={onWireStartDrag} onSelectNode={useStableCallback((multi: boolean) => { diff --git a/packages/app/src/components/NodeCanvas.tsx b/packages/app/src/components/NodeCanvas.tsx index 6e468fb77..48615600a 100644 --- a/packages/app/src/components/NodeCanvas.tsx +++ b/packages/app/src/components/NodeCanvas.tsx @@ -3,7 +3,7 @@ import { useNodeHeightCache } from '../hooks/useNodeBodyHeight'; import { DraggableNode } from './DraggableNode.js'; import { css } from '@emotion/react'; import { nodeStyles } from './nodeStyles.js'; -import { type FC, useMemo, useRef, useState, type MouseEvent, useEffect } from 'react'; +import { type FC, useMemo, useRef, useState, type MouseEvent, useEffect, useLayoutEffect } from 'react'; import { ContextMenu, type ContextMenuContext } from './ContextMenu.js'; import { CSSTransition } from 'react-transition-group'; import { WireLayer } from './WireLayer.js'; @@ -35,7 +35,7 @@ import { import { useCanvasPositioning } from '../hooks/useCanvasPositioning.js'; import { VisualNode } from './VisualNode.js'; import { useStableCallback } from '../hooks/useStableCallback.js'; -import { useThrottleFn } from 'ahooks'; +import { useThrottle, useThrottleFn } from 'ahooks'; import { produce } from 'immer'; import { graphMetadataState } from '../state/graph.js'; import { useViewportBounds } from '../hooks/useViewportBounds.js'; @@ -157,6 +157,8 @@ export interface NodeCanvasProps { export type PortPositions = Record; +const shouldShowNodeBasedOnPosition = new WeakMap(); + export const NodeCanvas: FC = ({ nodes, connections, @@ -209,7 +211,13 @@ export const NodeCanvas: FC = ({ setContextMenuData, } = useContextMenu(); - const { nodePortPositions, canvasRef, recalculate: recalculatePortPositions } = useNodePortPositions(); + const shouldRenderWires = canvasPosition.zoom > 0.15; + + const { + nodePortPositions, + canvasRef, + recalculate: recalculatePortPositions, + } = useNodePortPositions({ enabled: shouldRenderWires }); useEffect(() => { recalculatePortPositions(); @@ -577,6 +585,61 @@ export const NodeCanvas: FC = ({ const selectedProcessPagePerNode = useRecoilValue(selectedProcessPageNodesState); const isZoomedOut = canvasPosition.zoom < 0.4; + const isReallyZoomedOut = canvasPosition.zoom < 0.2; + + const [forceRender, setForceRender] = useState(0); + const movingRerenderTimeout = useRef(); + const lastNodeRecalculateTime = useRef(0); + + const isLargeGraph = nodes.length > 100; + const debounceTime = isLargeGraph ? 500 : 50; + + useLayoutEffect(() => { + const recalculateVisibleNodes = () => { + let numVisible = 0; + + for (const node of nodes) { + const isPinned = pinnedNodes.includes(node.id); + + const shouldHide = + (node.visualData.x < viewportBounds.left - (node.visualData.width ?? 300) || + node.visualData.x > viewportBounds.right + (node.visualData.width ?? 300) || + node.visualData.y < viewportBounds.top - 500 || + node.visualData.y > viewportBounds.bottom + 500) && + !isPinned; + + // if (numVisible > 300) { + // shouldHide = true; + // } + + shouldShowNodeBasedOnPosition.set(node, !shouldHide); + + if (!shouldHide) { + numVisible++; + } + } + + setForceRender((prev) => prev + 1); + lastNodeRecalculateTime.current = Date.now(); + }; + + if (movingRerenderTimeout.current) { + window.clearTimeout(movingRerenderTimeout.current); + } + + movingRerenderTimeout.current = window.setTimeout(() => { + recalculateVisibleNodes(); + }, debounceTime); + }, [ + pinnedNodes, + nodes, + viewportBounds.left, + viewportBounds.right, + viewportBounds.top, + viewportBounds.bottom, + forceRender, + debounceTime, + ]); return ( @@ -602,25 +665,19 @@ export const NodeCanvas: FC = ({ className="canvas-contents" style={{ transform: `scale(${canvasPosition.zoom}, ${canvasPosition.zoom}) translate(${canvasPosition.x}px, ${canvasPosition.y}px) translateZ(-1px)`, + willChange: 'transform', }} >
{nodesWithConnections.map(({ node, nodeConnections }) => { - const isPinned = pinnedNodes.includes(node.id); - - if ( - (node.visualData.x < viewportBounds.left - (node.visualData.width ?? 300) || - node.visualData.x > viewportBounds.right + (node.visualData.width ?? 300) || - node.visualData.y < viewportBounds.top - 500 || - node.visualData.y > viewportBounds.bottom + 500) && - !isPinned - ) { + if (!shouldShowNodeBasedOnPosition.get(node)) { return null; } if (draggingNodes.some((n) => n.id === node.id)) { return null; } + return ( = ({ lastRun={lastRunPerNode[node.id]} isPinned={pinnedNodes.includes(node.id)} isZoomedOut={isZoomedOut && node.type !== 'comment'} + isReallyZoomedOut={isReallyZoomedOut && node.type !== 'comment'} processPage={selectedProcessPagePerNode[node.id]!} draggingWire={draggingWire} onWireStartDrag={onWireStartDrag} @@ -676,6 +734,7 @@ export const NodeCanvas: FC = ({ isKnownNodeType={node.type in nodeTypes} isPinned={pinnedNodes.includes(node.id)} isZoomedOut={isZoomedOut && node.type !== 'comment'} + isReallyZoomedOut={isReallyZoomedOut && node.type !== 'comment'} processPage={selectedProcessPagePerNode[node.id]!} /> ))} @@ -715,14 +774,16 @@ export const NodeCanvas: FC = ({ /> )} - 0} - /> + {shouldRenderWires && ( + 0} + /> + )} {hoveringPort && hoveringShowPortInfo && ( )} diff --git a/packages/app/src/components/VisualNode.tsx b/packages/app/src/components/VisualNode.tsx index 47986730a..80f840bab 100644 --- a/packages/app/src/components/VisualNode.tsx +++ b/packages/app/src/components/VisualNode.tsx @@ -55,13 +55,14 @@ export type VisualNodeProps = { isDragging?: boolean; isOverlay?: boolean; isSelected?: boolean; - scale?: number; isKnownNodeType: boolean; isPinned: boolean; lastRun?: ProcessDataForNode[]; processPage: number | 'latest'; draggingWire?: DraggingWireDef; isZoomedOut: boolean; + isReallyZoomedOut: boolean; + renderSkeleton?: boolean; onWireStartDrag?: ( event: MouseEvent, startNodeId: NodeId, @@ -106,13 +107,14 @@ export const VisualNode = memo( yDelta = 0, isDragging, isOverlay, - scale, isSelected, isKnownNodeType, isPinned, lastRun, processPage, isZoomedOut, + isReallyZoomedOut, + renderSkeleton, onWireEndDrag, onWireStartDrag, onSelectNode, @@ -143,7 +145,7 @@ export const VisualNode = memo( const style: CSSProperties = { opacity: isDragging ? '0' : '', - transform: `translate(${node.visualData.x + xDelta}px, ${node.visualData.y + yDelta}px) scale(${scale ?? 1})`, + transform: `translate(${node.visualData.x + xDelta}px, ${node.visualData.y + yDelta}px) scale(${1})`, zIndex: isComment ? -10000 : node.visualData.zIndex ?? 0, width: node.visualData.width, height: isComment ? asCommentNode.data.height : undefined, @@ -162,12 +164,15 @@ export const VisualNode = memo( yDelta, node.visualData.width, isDragging, - scale, node.visualData.zIndex, isComment, asCommentNode.data.height, ]); + if (renderSkeleton) { + return
; + } + const nodeRef = (refValue: HTMLDivElement | null) => { if (typeof ref === 'function') { ref(refValue); @@ -176,8 +181,6 @@ export const VisualNode = memo( } }; - // const isZoomedOut = !isComment && zoom < 0.4; - const selectedProcessRun = lastRun && lastRun.length > 0 ? lastRun?.at(processPage === 'latest' ? lastRun.length - 1 : processPage)?.data @@ -222,6 +225,7 @@ export const VisualNode = memo( isKnownNodeType={isKnownNodeType} lastRun={lastRun} processPage={processPage} + isReallyZoomedOut={isReallyZoomedOut} /> ) : ( void; onStartEditing?: () => void; onWireStartDrag?: (event: MouseEvent, startNodeId: NodeId, startPortId: PortId) => void; @@ -281,6 +286,7 @@ const ZoomedOutVisualNodeContent: FC<{ isKnownNodeType, lastRun, processPage, + isReallyZoomedOut, onSelectNode, onStartEditing, onWireStartDrag, @@ -316,48 +322,52 @@ const ZoomedOutVisualNodeContent: FC<{ return ( <>
-
- {node.isSplitRun ? : <>} -
{node.title}
-
-
-
- {selectedProcessRun?.status ? ( - match(selectedProcessRun.status) - .with({ type: 'ok' }, () => ( -
- -
- )) - .with({ type: 'error' }, () => ( -
- -
- )) - .with({ type: 'running' }, () => ( -
- -
- )) - .with({ type: 'interrupted' }, () => ( -
- -
- )) - .with({ type: 'notRan' }, () => ( -
- -
- )) - .exhaustive() - ) : ( - <> - )} + {!isReallyZoomedOut && ( +
+ {node.isSplitRun ? : <>} +
{node.title}
- -
+ )} + {!isReallyZoomedOut && ( +
+
+ {selectedProcessRun?.status ? ( + match(selectedProcessRun.status) + .with({ type: 'ok' }, () => ( +
+ +
+ )) + .with({ type: 'error' }, () => ( +
+ +
+ )) + .with({ type: 'running' }, () => ( +
+ +
+ )) + .with({ type: 'interrupted' }, () => ( +
+ +
+ )) + .with({ type: 'notRan' }, () => ( +
+ +
+ )) + .exhaustive() + ) : ( + <> + )} +
+ +
+ )}
{isKnownNodeType && ( diff --git a/packages/app/src/components/Wire.tsx b/packages/app/src/components/Wire.tsx index aa49e4c65..e3c63e05b 100644 --- a/packages/app/src/components/Wire.tsx +++ b/packages/app/src/components/Wire.tsx @@ -37,13 +37,10 @@ export const ConditionallyRenderWire: FC = ({ return null; } - const start = getNodePortPosition(outputNode, connection.outputId, false, portPositions); - const end = getNodePortPosition(inputNode, connection.inputId, true, portPositions); + const [outputCacheKey, inputCacheKey] = getConnectionCacheKeys(connection); - // Optimization might not be needed - // if (!lineCrossesViewport(canvasToClientPosition(start.x, start.y), canvasToClientPosition(end.x, end.y))) { - // return null; - // } + const start = getNodePortPosition(outputNode, connection.outputId, outputCacheKey, portPositions); + const end = getNodePortPosition(inputNode, connection.inputId, inputCacheKey, portPositions); return ( }> @@ -71,7 +68,9 @@ export const PartialWire: FC<{ connection: PartialConnection; portPositions: Por return null; } - const start = getNodePortPosition(node, connection.portId, false, portPositions); + const cacheKey = `${connection.nodeId}-output-${connection.portId}`; + + const start = getNodePortPosition(node, connection.portId, cacheKey, portPositions); const end = { x: connection.toX, y: connection.toY }; return ( @@ -116,7 +115,7 @@ Wire.displayName = 'Wire'; export function getNodePortPosition( node: ChartNode, portId: PortId, - portIsInput: boolean, + cacheKey: string, portPositions: PortPositions, ): { x: number; y: number } { if (!node) { @@ -124,8 +123,7 @@ export function getNodePortPosition( } if (portId) { - const key = `${node.id}-${portIsInput ? 'input' : 'output'}-${portId}`; - const portPosition = portPositions[key]; + const portPosition = portPositions[cacheKey]; if (portPosition) { return { x: portPosition.x, y: portPosition.y }; } else { @@ -138,3 +136,21 @@ export function getNodePortPosition( return { x: 0, y: 0 }; } + +const cacheKeysByConnection = new WeakMap(); + +export function getConnectionCacheKeys(connection: NodeConnection): readonly [string, string] { + const cached = cacheKeysByConnection.get(connection); + if (cached) { + return cached; + } + + const cacheKeys = [ + `${connection.outputNodeId}-output-${connection.outputId}`, + `${connection.inputNodeId}-input-${connection.inputId}`, + ] as const; + + cacheKeysByConnection.set(connection, cacheKeys); + + return cacheKeys; +} diff --git a/packages/app/src/components/WireLayer.tsx b/packages/app/src/components/WireLayer.tsx index c990d9601..9150fc377 100644 --- a/packages/app/src/components/WireLayer.tsx +++ b/packages/app/src/components/WireLayer.tsx @@ -1,8 +1,8 @@ -import { type FC, useCallback, useEffect, useLayoutEffect, useState } from 'react'; +import { type FC, useCallback, useEffect, useLayoutEffect, useState, useMemo } from 'react'; import { type NodeConnection, type NodeId, type PortId } from '@ironclad/rivet-core'; import { css } from '@emotion/react'; -import { ConditionallyRenderWire, PartialWire } from './Wire.js'; -import { useCanvasPositioning } from '../hooks/useCanvasPositioning.js'; +import { ConditionallyRenderWire, PartialWire, getConnectionCacheKeys, getNodePortPosition } from './Wire.js'; +import { canvasToClientPosition, useCanvasPositioning } from '../hooks/useCanvasPositioning.js'; import { ErrorBoundary } from 'react-error-boundary'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { draggingWireClosestPortState } from '../state/graphBuilder.js'; @@ -12,6 +12,7 @@ import { type PortPositions } from './NodeCanvas'; import { type RunDataByNodeId, lastRunDataByNodeState, selectedProcessPageNodesState } from '../state/dataFlow'; import select from '@atlaskit/select/dist/types/entry-points/select'; import { useStableCallback } from '../hooks/useStableCallback'; +import { lineCrossesViewport } from '../utils/lineClipping'; const wiresStyles = css` width: 100%; @@ -134,11 +135,35 @@ export const WireLayer: FC = ({ useLayoutEffect(() => {}, [draggingWire, mousePosition.x, mousePosition.y, setClosestPort]); - const { canvasPosition, clientToCanvasPosition } = useCanvasPositioning(); + const { canvasPosition, clientToCanvasPosition, canvasToClientPosition } = useCanvasPositioning(); const mousePositionCanvas = clientToCanvasPosition(mousePosition.x, mousePosition.y); const nodesById = useRecoilValue(nodesByIdState); + // Despite having to run getNodePortPositions in ConditionallyRenderWire, it's still faster to filter here + // using lineCrossesViewport, especially for gigantic graphs when zoomed in. Avoiding rendering thousands of + // and helps with the performance. + const renderableWires = useMemo(() => { + return connections.filter((connection) => { + const inputNode = nodesById[connection.inputNodeId]; + const outputNode = nodesById[connection.outputNodeId]; + + if (!inputNode || !outputNode) { + return false; + } + + const [outputCacheKey, inputCacheKey] = getConnectionCacheKeys(connection); + + const start = getNodePortPosition(outputNode, connection.outputId, outputCacheKey, portPositions); + const end = getNodePortPosition(inputNode, connection.inputId, inputCacheKey, portPositions); + + const startClient = canvasToClientPosition(start.x, start.y); + const endClient = canvasToClientPosition(end.x, end.y); + + return lineCrossesViewport(startClient, endClient); + }); + }, [nodesById, canvasToClientPosition, connections, portPositions]); + return ( @@ -171,7 +196,7 @@ export const WireLayer: FC = ({ )} )} - {connections.map((connection) => { + {renderableWires.map((connection) => { const isHighlightedNode = highlightedNodes?.includes(connection.inputNodeId) || highlightedNodes?.includes(connection.outputNodeId); diff --git a/packages/app/src/components/nodeStyles.ts b/packages/app/src/components/nodeStyles.ts index 83b08c243..0c2387b91 100644 --- a/packages/app/src/components/nodeStyles.ts +++ b/packages/app/src/components/nodeStyles.ts @@ -18,6 +18,12 @@ export const nodeStyles = css` transition-timing-function: ease-out; transition-property: box-shadow, border-color; transform-origin: top left; + contain: layout; + } + + .node-skeleton { + background: var(--grey-light); + height: 100px; } .node.isComment { diff --git a/packages/app/src/hooks/useNodePortPositions.ts b/packages/app/src/hooks/useNodePortPositions.ts index a16940f73..b77b5a3b9 100644 --- a/packages/app/src/hooks/useNodePortPositions.ts +++ b/packages/app/src/hooks/useNodePortPositions.ts @@ -10,12 +10,16 @@ import { nodesByIdState } from '../state/graph'; * It's done this way with a nodePortPositions state using rounded numbers for performance reasons. * In the ideal case, no position will have changed, so the state does not update. */ -export function useNodePortPositions() { +export function useNodePortPositions({ enabled }: { enabled: boolean }) { const [nodePortPositions, setNodePortPositions] = useState({}); const nodesById = useRecoilValue(nodesByIdState); const canvasRef = useRef(null); const recalculate = useCallback(() => { + if (!enabled) { + return; + } + // Lot of duplication but meh const normalPortElements = canvasRef.current?.querySelectorAll( '.node:not(.overlayNode) .port-circle', @@ -121,7 +125,7 @@ export function useNodePortPositions() { if (changed) { setNodePortPositions(newPositions); } - }, [nodePortPositions, nodesById]); + }, [nodePortPositions, nodesById, enabled]); useLayoutEffect(() => { recalculate();