Skip to content

Commit

Permalink
feat(graph): Add selection based zoom
Browse files Browse the repository at this point in the history
  • Loading branch information
jeff-phillips-18 committed Nov 1, 2024
1 parent 2a1b615 commit ddd06cd
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DragObjectWithType,
Node,
withPanZoom,
withDragZoom,
GraphComponent,
withCreateConnector,
Graph,
Expand Down Expand Up @@ -60,7 +61,7 @@ const demoComponentFactory: ComponentFactory = (
type: string
): React.ComponentType<{ element: GraphElement }> | undefined => {
if (kind === ModelKind.graph) {
return withDndDrop(graphDropTargetSpec([NODE_DRAG_TYPE]))(withPanZoom()(GraphComponent));
return withDndDrop(graphDropTargetSpec([NODE_DRAG_TYPE]))(withPanZoom()(withDragZoom()(GraphComponent)));
}
switch (type) {
case 'node':
Expand Down
1 change: 1 addition & 0 deletions packages/module/src/behavior/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './useDndDrop';
export * from './useDndManager';
export * from './useDragNode';
export * from './usePanZoom';
export * from './useDragZoom';
export * from './useReconnect';
export * from './useSelection';
export * from './usePolygonAnchor';
Expand Down
137 changes: 137 additions & 0 deletions packages/module/src/behavior/useDragZoom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as React from 'react';
import * as d3 from 'd3';
import { observer } from 'mobx-react';
import { action } from 'mobx';
import ElementContext from '../utils/ElementContext';
import useCallbackRef from '../utils/useCallbackRef';
import { Graph, isGraph } from '../types';
import { PanZoomRef } from './usePanZoom';
import Point from '../geom/Point';

export type DragZoomRef = (node: SVGGElement | null) => void;

// Used to send events prevented by d3.zoom to the document allowing modals, dropdowns, etc, to close
const propagateDragZoomMouseEvent = (e: Event): void => {
document.dispatchEvent(new MouseEvent(e.type, e));
};

export const useDragZoom = (): WithDragZoomProps => {
const element = React.useContext(ElementContext);
const [draggingState, setDraggingState] = React.useState<Omit<WithDragZoomProps, 'dragZoomRef'>>({});

if (!isGraph(element)) {
throw new Error('useDragZoom must be used within the scope of a Graph');
}
const elementRef = React.useRef<Graph>(element);
elementRef.current = element;

const zoomToDragArea = (dragStart: Point, dragEnd: Point) => {
const currentScale = elementRef.current.getScale();
const graphPosition = elementRef.current.getPosition();

const x = (Math.min(dragStart.x, dragEnd.x) - graphPosition.x) / currentScale;
const y = (Math.min(dragStart.y, dragEnd.y) - graphPosition.y) / currentScale;
const width = Math.abs(dragEnd.x - dragStart.x) / currentScale;
const height = Math.abs(dragEnd.y - dragStart.y) / currentScale;

if (width < 10 || height < 10) {
return;
}

const { width: fullWidth, height: fullHeight } = elementRef.current.getDimensions();

// compute the scale
const xScale = fullWidth / width;
const yScale = fullHeight / height;
const scale = Math.min(xScale, yScale);

// translate to center
const midX = x + width / 2;
const midY = y + height / 2;
const tx = fullWidth / 2 - midX * scale;
const ty = fullHeight / 2 - midY * scale;

elementRef.current.setScale(scale);
elementRef.current.setPosition(new Point(tx, ty));
};

const dragZoomRef = useCallbackRef<PanZoomRef>((node: SVGGElement | null) => {
if (node) {
// TODO fix any type
const $svg = d3.select(node.ownerSVGElement) as any;
if (node && node.ownerSVGElement) {
node.ownerSVGElement.addEventListener('mousedown', propagateDragZoomMouseEvent);
node.ownerSVGElement.addEventListener('click', propagateDragZoomMouseEvent);
}
const drag = d3
.drag()
.on(
'start',
action((event: d3.D3DragEvent<Element, any, any>) => {
const { offsetX, offsetY } =
event.sourceEvent instanceof MouseEvent ? event.sourceEvent : { offsetX: 0, offsetY: 0 };
const { width: maxX, height: maxY } = elementRef.current.getDimensions();

const startPoint = new Point(Math.min(Math.max(offsetX, 0), maxX), Math.min(Math.max(offsetY, 0), maxY));
setDraggingState({
isZoomDragging: true,
zoomDragStart: startPoint,
zoomDragEnd: startPoint
});
})
)
.on(
'drag',
action((event: d3.D3DragEvent<Element, any, any>) => {
const { offsetX, offsetY } =
event.sourceEvent instanceof MouseEvent ? event.sourceEvent : { offsetX: 0, offsetY: 0 };
const { width: maxX, height: maxY } = elementRef.current.getDimensions();
setDraggingState((prev) => ({
...prev,
zoomDragEnd: new Point(Math.min(Math.max(offsetX, 0), maxX), Math.min(Math.max(offsetY, 0), maxY))
}));
})
)
.on(
'end',
action(() => {
setDraggingState((prev) => {
zoomToDragArea(prev.zoomDragStart, prev.zoomDragEnd);
return { isZoomDragging: false };
});
})
)
.filter((event: React.MouseEvent) => event.ctrlKey && !event.button);
drag($svg);
}

return () => {
if (node) {
// remove all drag listeners
d3.select(node.ownerSVGElement).on('.drag', null);
if (node.ownerSVGElement) {
node.ownerSVGElement.removeEventListener('mousedown', propagateDragZoomMouseEvent);
node.ownerSVGElement.removeEventListener('click', propagateDragZoomMouseEvent);
}
}
};
});
return { dragZoomRef, ...draggingState };
};
export interface WithDragZoomProps {
dragZoomRef?: DragZoomRef;
isZoomDragging?: boolean;
zoomDragStart?: Point;
zoomDragEnd?: Point;
}

export const withDragZoom =
() =>
<P extends WithDragZoomProps>(WrappedComponent: React.ComponentType<P>) => {
const Component: React.FunctionComponent<Omit<P, keyof WithDragZoomProps>> = (props) => {
const dragZoomProps = useDragZoom();
return <WrappedComponent {...(props as any)} {...dragZoomProps} />;
};
Component.displayName = `withPanZoom(${WrappedComponent.displayName || WrappedComponent.name})`;
return observer(Component);
};
22 changes: 20 additions & 2 deletions packages/module/src/components/GraphComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import * as React from 'react';
import { observer } from 'mobx-react';
import { Graph, isGraph } from '../types';
import { WithPanZoomProps } from '../behavior/usePanZoom';
import { WithDragZoomProps } from '../behavior/useDragZoom';
import { WithDndDropProps } from '../behavior/useDndDrop';
import { WithSelectionProps } from '../behavior/useSelection';
import { WithContextMenuProps } from '../behavior/withContextMenu';
import LayersProvider from './layers/LayersProvider';
import ElementWrapper from './ElementWrapper';
import { GraphElementProps } from './factories';
import useCombineRefs from '../utils/useCombineRefs';
import styles from '../css/topology-components';

type GraphComponentProps = GraphElementProps &
WithPanZoomProps &
WithDragZoomProps &
WithDndDropProps &
WithSelectionProps &
WithContextMenuProps;
Expand Down Expand Up @@ -39,10 +43,15 @@ const Inner: React.FunctionComponent<{ element: Graph }> = React.memo(
const GraphComponent: React.FunctionComponent<GraphComponentProps> = ({
element,
panZoomRef,
dragZoomRef,
dndDropRef,
onSelect,
onContextMenu
onContextMenu,
isZoomDragging,
zoomDragStart,
zoomDragEnd
}) => {
const zoomRefs = useCombineRefs(panZoomRef, dragZoomRef);
if (!isGraph(element)) {
return null;
}
Expand All @@ -60,9 +69,18 @@ const GraphComponent: React.FunctionComponent<GraphComponentProps> = ({
onClick={onSelect}
onContextMenu={onContextMenu}
/>
<g data-surface="true" ref={panZoomRef} transform={`translate(${x}, ${y}) scale(${graphElement.getScale()})`}>
<g data-surface="true" ref={zoomRefs} transform={`translate(${x}, ${y}) scale(${graphElement.getScale()})`}>
<Inner element={graphElement} />
</g>
{isZoomDragging && zoomDragStart && zoomDragEnd ? (
<rect
className={styles.topologyZoomRect}
x={Math.min(zoomDragStart.x, zoomDragEnd.x)}
y={Math.min(zoomDragStart.y, zoomDragEnd.y)}
width={Math.abs(zoomDragEnd.x - zoomDragStart.x)}
height={Math.abs(zoomDragEnd.y - zoomDragStart.y)}
/>
) : null}
</>
);
};
Expand Down
7 changes: 7 additions & 0 deletions packages/module/src/css/topology-components.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@
--pf-topology-default-create-connector--m-hover--line--Stroke: var(--pf-v5-global--Color--100);
--pf-topology-default-create-connector--m-hover--arrow--Fill: var(--pf-v5-global--Color--100);
--pf-topology-default-create-connector--m-hover--arrow--Stroke: var(--pf-v5-global--Color--100);

--pf-topology__zoom-rect--Fill: var(--pf-v5-global--secondary-color--100);
--pf-topology__zoom-rect--Opacity: 0.35;
}

/* DARK THEME OVERRIDES */
Expand Down Expand Up @@ -861,3 +864,7 @@
fill: var(--pf-topology__create-connector-color--Fill);
}

.pf-topology-zoom-rect {
fill: var(--pf-topology__zoom-rect--Fill);
opacity: var(--pf-topology__zoom-rect--Opacity);
}

0 comments on commit ddd06cd

Please sign in to comment.