Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(graph): Add selection based zoom
Browse files Browse the repository at this point in the history
jeff-phillips-18 committed Nov 12, 2024
1 parent 2a1b615 commit a9b8320
Showing 11 changed files with 382 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.area-drag-hint__hint-container {
justify-content: center;
display: flex;
pointer-events: none;
position: absolute;
top: var(--pf-v5-global--spacer--sm);
left: 0;
right: 0;
z-index: 99;
}
.area-drag-hint__hint-background {
background-color: var(--pf-v5-global--BackgroundColor--100);
border: 1px solid var(--pf-v5-global--BorderColor--light-100);
border-radius: 8px;
padding: var(--pf-v5-global--spacer--xs) var(--pf-v5-global--spacer--sm);
pointer-events: none;
}

.area-drag-hint {
align-items: center;
display: flex;
}
.area-drag-hint__icon {
color: var(--pf-v5-global--palette--blue-300);
}
.area-drag-hint__text {
margin-left: var(--pf-v5-global--spacer--sm);
}
.area-drag-hint-shortcut__cell {
padding-left: var(--pf-v5-global--spacer--sm);
}

.area-drag-hint-shortcut__command:not(:last-child):after {
content: ' + ';
}

.area-drag-hint-shortcut__kbd {
border: var(--pf-v5-global--BorderWidth--sm) solid var(--pf-v5-global--BorderColor--100);
border-radius: 3px;
color: var(--pf-v5-global--Color--200);
font-size: var(--pf-v5-global--FontSize--sm);
padding: 1px 3px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import { InfoCircleIcon, MouseIcon } from '@patternfly/react-icons';

import './AreaDragHint.css';

const AreaDragHint: React.FC = () => {
return (
<div className="area-drag-hint__hint-container">
<div className="area-drag-hint__hint-background">
<div className="area-drag-hint">
<InfoCircleIcon className="area-drag-hint__icon" />
<span className="area-drag-hint__text">
<table>
<tbody>
<tr>
<td className="area-drag-hint-shortcut__cell">
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">Shift</kbd>
</span>
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">
<MouseIcon /> Drag
</kbd>
</span>
</td>
<td className="area-drag-hint-shortcut__cell">Select nodes in area</td>
</tr>
<tr>
<td className="area-drag-hint-shortcut__cell">
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">Ctrl</kbd>
</span>
<span className="area-drag-hint-shortcut__command">
<kbd className="area-drag-hint-shortcut__kbd">
<MouseIcon /> Drag
</kbd>
</span>
</td>
<td className="area-drag-hint-shortcut__cell">Zoom to selected area</td>
</tr>
</tbody>
</table>
</span>
</div>
</div>
</div>
);
};

export default AreaDragHint;
Original file line number Diff line number Diff line change
@@ -12,7 +12,11 @@ import {
Visualization,
VisualizationProvider,
VisualizationSurface,
observer
observer,
GraphAreaSelectedEventListener,
GRAPH_AREA_SELECTED_EVENT,
GraphAreaDraggingEvent,
GRAPH_AREA_DRAGGING_EVENT
} from '@patternfly/react-topology';
import defaultLayoutFactory from '../../layouts/defaultLayoutFactory';
import defaultComponentFactory from '../../components/defaultComponentFactory';
@@ -23,6 +27,7 @@ import { DemoContext } from './DemoContext';
import demoComponentFactory from './demoComponentFactory';
import { graphPositionChangeListener, layoutEndListener } from './listeners';
import DemoControlBar from '../DemoControlBar';
import AreaDragHint from './AreaDragHint';

interface TopologyViewComponentProps {
useSidebar: boolean;
@@ -32,6 +37,7 @@ interface TopologyViewComponentProps {
const TopologyViewComponent: React.FunctionComponent<TopologyViewComponentProps> = observer(
({ useSidebar, sideBarResizable = false }) => {
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [showAreaDragHint, setShowAreaDragHint] = React.useState<boolean>(false);
const controller = useVisualizationController();
const options = React.useContext(DemoContext);

@@ -59,6 +65,31 @@ const TopologyViewComponent: React.FunctionComponent<TopologyViewComponentProps>
setSelectedIds(ids);
});

useEventListener<GraphAreaSelectedEventListener>(
GRAPH_AREA_SELECTED_EVENT,
({ graph, modifier, startPoint, endPoint }) => {
if (modifier === 'ctrlKey') {
graph.zoomToSelection(startPoint, endPoint);
return;
}
if (modifier === 'shiftKey') {
const selections = graph.nodesInSelection(startPoint, endPoint);
setSelectedIds(
selections.reduce((acc, node) => {
if (!node.isGroup()) {
acc.push(node.getId());
}
return acc;
}, [])
);
}
}
);

useEventListener<GraphAreaDraggingEvent>(GRAPH_AREA_DRAGGING_EVENT, ({ isDragging }) => {
setShowAreaDragHint(isDragging);
});

React.useEffect(() => {
let resizeTimeout: NodeJS.Timeout;

@@ -111,6 +142,7 @@ const TopologyViewComponent: React.FunctionComponent<TopologyViewComponentProps>
sideBarOpen={useSidebar && !!selectedIds?.length}
sideBarResizable={sideBarResizable}
>
{showAreaDragHint ? <AreaDragHint /> : null}
<VisualizationSurface state={{ selectedIds }} />
</TopologyView>
);
Original file line number Diff line number Diff line change
@@ -10,8 +10,9 @@ import {
ModelKind,
DragObjectWithType,
Node,
withPanZoom,
GraphComponent,
withPanZoom,
withAreaSelection,
withCreateConnector,
Graph,
isNode,
@@ -60,7 +61,9 @@ 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()(withAreaSelection(['ctrlKey', 'shiftKey'])(GraphComponent))
);
}
switch (type) {
case 'node':
1 change: 1 addition & 0 deletions packages/module/src/behavior/index.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ export * from './useDndDrop';
export * from './useDndManager';
export * from './useDragNode';
export * from './usePanZoom';
export * from './useAreaSelection';
export * from './useReconnect';
export * from './useSelection';
export * from './usePolygonAnchor';
121 changes: 121 additions & 0 deletions packages/module/src/behavior/useAreaSelection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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, GRAPH_AREA_DRAGGING_EVENT, GRAPH_AREA_SELECTED_EVENT, isGraph, ModifierKey } from '../types';
import Point from '../geom/Point';

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

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

export const useAreaSelection = (modifiers: ModifierKey[] = ['ctrlKey']): WithAreaSelectionProps => {
const element = React.useContext(ElementContext);
const [draggingState, setDraggingState] = React.useState<Omit<WithAreaSelectionProps, 'areaSelectionRef'>>({});

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

const areaSelectionRef = useCallbackRef<AreaSelectionRef>((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', propagateAreaSelectionMouseEvent);
node.ownerSVGElement.addEventListener('click', propagateAreaSelectionMouseEvent);
}
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));
const modifier = modifiers.find((m) => event.sourceEvent[m]);

setDraggingState({
modifier,
isAreaSelectDragging: true,
areaSelectDragStart: startPoint,
areaSelectDragEnd: startPoint
});
elementRef.current
.getController()
.fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: true });
})
)
.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,
areaSelectDragEnd: new Point(Math.min(Math.max(offsetX, 0), maxX), Math.min(Math.max(offsetY, 0), maxY))
}));
})
)
.on(
'end',
action(() => {
setDraggingState((prev) => {
elementRef.current.getController().fireEvent(GRAPH_AREA_SELECTED_EVENT, {
graph: elementRef.current,
modifier: prev.modifier,
startPoint: prev.areaSelectDragStart,
endPoint: prev.areaSelectDragEnd
});
return { isAreaSelectDragging: false };
});
elementRef.current
.getController()
.fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: false });
})
)
.filter((event: React.MouseEvent) => modifiers.find((m) => event[m]) && !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', propagateAreaSelectionMouseEvent);
node.ownerSVGElement.removeEventListener('click', propagateAreaSelectionMouseEvent);
}
}
};
});
return { areaSelectionRef, ...draggingState };
};
export interface WithAreaSelectionProps {
areaSelectionRef?: AreaSelectionRef;
modifier?: ModifierKey;
isAreaSelectDragging?: boolean;
areaSelectDragStart?: Point;
areaSelectDragEnd?: Point;
}

export const withAreaSelection =
(modifier: ModifierKey[] = ['ctrlKey']) =>
<P extends WithAreaSelectionProps>(WrappedComponent: React.ComponentType<P>) => {
const Component: React.FunctionComponent<Omit<P, keyof WithAreaSelectionProps>> = (props) => {
const areaSelectionProps = useAreaSelection(modifier);
return <WrappedComponent {...(props as any)} {...areaSelectionProps} />;
};
Component.displayName = `withAreaSelection(${WrappedComponent.displayName || WrappedComponent.name})`;
return observer(Component);
};
17 changes: 15 additions & 2 deletions packages/module/src/behavior/usePanZoom.tsx
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import { action, autorun, IReactionDisposer } from 'mobx';
import ElementContext from '../utils/ElementContext';
import useCallbackRef from '../utils/useCallbackRef';
import Point from '../geom/Point';
import { Graph, isGraph, ModelKind } from '../types';
import { Graph, GRAPH_AREA_DRAGGING_EVENT, isGraph, ModelKind } from '../types';
import { ATTR_DATA_KIND } from '../const';

export type PanZoomRef = (node: SVGGElement | null) => void;
@@ -38,12 +38,25 @@ export const usePanZoom = (): PanZoomRef => {
.on(
'zoom',
action((event: d3.D3ZoomEvent<any, any>) => {
if (event.sourceEvent?.type === 'mousemove') {
elementRef.current
.getController()
.fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: true });
}
elementRef.current.setPosition(new Point(event.transform.x, event.transform.y));
elementRef.current.setScale(event.transform.k);
})
)
.on(
'end',
action(() => {
elementRef.current
.getController()
.fireEvent(GRAPH_AREA_DRAGGING_EVENT, { graph: elementRef.current, isDragging: false });
})
)
.filter((event: React.MouseEvent) => {
if (event.ctrlKey || event.button) {
if (event.ctrlKey || event.shiftKey || event.altKey || event.button) {
return false;
}
// only allow zoom from double clicking the graph directly
22 changes: 20 additions & 2 deletions packages/module/src/components/GraphComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import * as React from 'react';
import { observer } from 'mobx-react';
import { Graph, isGraph } from '../types';
import styles from '../css/topology-components';
import { WithPanZoomProps } from '../behavior/usePanZoom';
import { WithAreaSelectionProps } from '../behavior/useAreaSelection';
import { WithDndDropProps } from '../behavior/useDndDrop';
import { WithSelectionProps } from '../behavior/useSelection';
import { WithContextMenuProps } from '../behavior/withContextMenu';
import useCombineRefs from '../utils/useCombineRefs';
import LayersProvider from './layers/LayersProvider';
import ElementWrapper from './ElementWrapper';
import { GraphElementProps } from './factories';

type GraphComponentProps = GraphElementProps &
WithPanZoomProps &
WithAreaSelectionProps &
WithDndDropProps &
WithSelectionProps &
WithContextMenuProps;
@@ -39,10 +43,15 @@ const Inner: React.FunctionComponent<{ element: Graph }> = React.memo(
const GraphComponent: React.FunctionComponent<GraphComponentProps> = ({
element,
panZoomRef,
areaSelectionRef,
dndDropRef,
onSelect,
onContextMenu
onContextMenu,
isAreaSelectDragging,
areaSelectDragStart,
areaSelectDragEnd
}) => {
const zoomRefs = useCombineRefs(panZoomRef, areaSelectionRef);
if (!isGraph(element)) {
return null;
}
@@ -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>
{isAreaSelectDragging && areaSelectDragStart && areaSelectDragEnd ? (
<rect
className={styles.topologyAreaSelectRect}
x={Math.min(areaSelectDragStart.x, areaSelectDragEnd.x)}
y={Math.min(areaSelectDragStart.y, areaSelectDragEnd.y)}
width={Math.abs(areaSelectDragEnd.x - areaSelectDragStart.x)}
height={Math.abs(areaSelectDragEnd.y - areaSelectDragStart.y)}
/>
) : null}
</>
);
};
8 changes: 8 additions & 0 deletions packages/module/src/css/topology-components.css
Original file line number Diff line number Diff line change
@@ -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__area-select-rect--Fill: var(--pf-v5-global--palette--black-500);
--pf-topology__area-select-rect--Opacity: 0.4;
}

/* DARK THEME OVERRIDES */
@@ -245,6 +248,7 @@
--pf-topology__edge__tag__text--Fill: var(--pf-v5-global--palette--black-900);
--pf-topology__edge__tag__text--Stroke: var(--pf-v5-global--palette--black-900);

--pf-topology__area-select-rect--Fill: var(--pf-v5-global--palette--black-300);
}

.pf-topology-visualization-surface {
@@ -861,3 +865,7 @@
fill: var(--pf-topology__create-connector-color--Fill);
}

.pf-topology-area-select-rect {
fill: var(--pf-topology__area-select-rect--Fill);
opacity: var(--pf-topology__area-select-rect--Opacity);
}
75 changes: 75 additions & 0 deletions packages/module/src/elements/BaseGraph.ts
Original file line number Diff line number Diff line change
@@ -369,6 +369,81 @@ export default class BaseGraph<E extends GraphModel = GraphModel, D = any>
}
};

zoomToSelection = (startPoint: Point, endPoint: Point) => {
const currentScale = this.getScale();
const graphPosition = this.getPosition();

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

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

const { width: fullWidth, height: fullHeight } = this.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;

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

isInBounds = (node: Node, bounds: Rect): boolean => {
const { x, y, width, height } = node.getBounds();
return (
x + width >= bounds.x && x <= bounds.x + bounds.width && y + height >= bounds.y && y <= bounds.y + bounds.height
);
};

childrenInBounds = (node: Node, bounds: Rect): Node[] => {
if (!node.isGroup() || node.isCollapsed()) {
return [];
}
const nodes: Node[] = [];
node.getChildren().forEach((child) => {
if (isNode(child)) {
if (this.isInBounds(child, bounds)) {
nodes.push(child);
nodes.push(...this.childrenInBounds(child, bounds));
}
}
});
return nodes;
};

nodesInSelection = (startPoint: Point, endPoint: Point): Node[] => {
const currentScale = this.getScale();
const graphPosition = this.getPosition();
const x = (Math.min(startPoint.x, endPoint.x) - graphPosition.x) / currentScale;
const y = (Math.min(startPoint.y, endPoint.y) - graphPosition.y) / currentScale;
const width = Math.abs(endPoint.x - startPoint.x) / currentScale;
const height = Math.abs(endPoint.y - startPoint.y) / currentScale;

const bounds = new Rect(x, y, width, height);

const selections: Node[] = [];

this.getNodes().forEach((child) => {
if (this.isInBounds(child, bounds)) {
selections.push(child);
selections.push(...this.childrenInBounds(child, bounds));
}
});

return selections;
};

isNodeInView(element: Node<NodeModel, any>, { padding = 0 }): boolean {
const graph = element.getGraph();
const { x: viewX, y: viewY, width: viewWidth, height: viewHeight } = graph.getBounds();
11 changes: 11 additions & 0 deletions packages/module/src/types.ts
Original file line number Diff line number Diff line change
@@ -287,6 +287,8 @@ export interface Graph<E extends GraphModel = GraphModel, D = any> extends Graph
fit(padding?: number, node?: Node): void;
centerInView(nodeElement: Node): void;
panIntoView(element: Node, options?: { offset?: number; minimumVisible?: number }): void;
zoomToSelection(startPoint: Point, endPoint: Point): void;
nodesInSelection(startPoint: Point, endPoint: Point): Node[];
isNodeInView(element: Node, options?: { padding: number }): boolean;
expandAll(): void;
collapseAll(): void;
@@ -356,10 +358,19 @@ export type NodeCollapseChangeEventListener = EventListener<[{ node: Node }]>;

export type GraphLayoutEndEventListener = EventListener<[{ graph: Graph }]>;

export type ModifierKey = 'ctrlKey' | 'shiftKey' | 'altKey';

export type GraphAreaDraggingEvent = EventListener<[{ graph: Graph; isDragging: boolean }]>;
export type GraphAreaSelectedEventListener = EventListener<
[{ graph: Graph; modifier: ModifierKey; startPoint: Point; endPoint: Point }]
>;

export const ADD_CHILD_EVENT = 'element-add-child';
export const ELEMENT_VISIBILITY_CHANGE_EVENT = 'element-visibility-change';
export const REMOVE_CHILD_EVENT = 'element-remove-child';
export const NODE_COLLAPSE_CHANGE_EVENT = 'node-collapse-change';
export const NODE_POSITIONED_EVENT = 'node-positioned';
export const GRAPH_LAYOUT_END_EVENT = 'graph-layout-end';
export const GRAPH_POSITION_CHANGE_EVENT = 'graph-position-change';
export const GRAPH_AREA_DRAGGING_EVENT = 'graph-area-dragging';
export const GRAPH_AREA_SELECTED_EVENT = 'graph-area-selected';

0 comments on commit a9b8320

Please sign in to comment.