Skip to content

Commit

Permalink
feat: Add zoom in/out and panning [PT-187321155]
Browse files Browse the repository at this point in the history
To enable zooming and panning the graph was updated to use a single root element which the zoom/pan transformation is applied to.  The transformation is also reported to the drawing component so that it can correctly place new nodes or draw the rubberband edge.
  • Loading branch information
dougmartin committed Apr 8, 2024
1 parent 6cb74bf commit ef46009
Show file tree
Hide file tree
Showing 2 changed files with 63 additions and 27 deletions.
14 changes: 10 additions & 4 deletions src/components/drawing.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { nanoid } from "nanoid";

import { DrawingMode, Graph, Point, RubberBand } from "./graph";
import { DrawingMode, Graph, Point, RubberBand, Transform } from "./graph";
import { Edge, GraphData, Node } from "../type";

import { DragIcon } from "./drawing/drag-icon";
Expand Down Expand Up @@ -38,6 +38,7 @@ export const Drawing = (props: Props) => {
const [selectedNodeForModal, setSelectedNodeForModal] = useState<Node|undefined>(undefined);
const widthRef = useRef(0);
const heightRef = useRef(0);
const transformRef = useRef<Transform>();

const setSelectedNodeId = useCallback((id?: string, skipToggle?: boolean) => {
if (drawingMode === "select") {
Expand All @@ -50,11 +51,16 @@ export const Drawing = (props: Props) => {
heightRef.current = height;
};

const handleTransformed = (transform: Transform) => {
transformRef.current = transform;
};

const translateToGraphPoint = (e: MouseEvent|React.MouseEvent<HTMLDivElement>): Point => {
// the offsets were determined visually to put the state centered on the mouse
const {x, y, k} = transformRef.current ?? {x: 0, y: 0, k: 1};
return {
x: e.clientX - 50 - (widthRef.current / 2),
y: e.clientY - 10 - (heightRef.current / 2),
x: ((e.clientX - 50 - (widthRef.current / 2)) - x) / k,
y: ((e.clientY - 10 - (heightRef.current / 2)) - y) / k,
};
};

Expand Down Expand Up @@ -106,7 +112,6 @@ export const Drawing = (props: Props) => {
*/

const addNode = useCallback(({x, y}: {x: number, y: number}) => {
console.log("ADD NODE");
setGraph(prev => {
const id = nanoid();
const label = `State ${prev.nodes.length + 1}`;
Expand Down Expand Up @@ -279,6 +284,7 @@ export const Drawing = (props: Props) => {
onDragStop={handleDragStop}
setSelectedNodeId={setSelectedNodeId}
onDimensions={handleDimensionChange}
onTransformed={handleTransformed}
/>
<DragIcon drawingMode={drawingMode} />
<NodeModal
Expand Down
76 changes: 53 additions & 23 deletions src/components/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const unselectedLoopArrowUrl = "url(#unselectedLoopArrow)";

export type DrawingMode = "select"|"addNode"|"addEdge"|"delete";

export type Transform = {x: number, y: number, k: number};

export type GraphSettings = {
minRadius: number;
maxRadius: number;
Expand Down Expand Up @@ -59,6 +61,7 @@ type Props = {
onEdgeClick?: (options: {from: string, to: string}) => void;
onDragStop?: (id: string, pos: Point) => void;
onDimensions?: (dimensions: {width: number, height: number}) => void;
onTransformed?: (transform: Transform) => void;
setSelectedNodeId: (id?: string, skipToggle?: boolean) => void;
};

Expand Down Expand Up @@ -237,7 +240,7 @@ export const Graph = (props: Props) => {
const {graph, highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes,
allowDragging, autoArrange, rubberBand, drawingMode,
onClick, onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop,
selectedNodeId, setSelectedNodeId, animating, onDimensions} = props;
selectedNodeId, setSelectedNodeId, animating, onDimensions, onTransformed} = props;
const svgRef = useRef<SVGSVGElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const dimensions = useResizeObserver(wrapperRef);
Expand All @@ -247,8 +250,9 @@ export const Graph = (props: Props) => {
const lastClickTimeRef = useRef<number|undefined>(undefined);
const lastClickIdRef = useRef<string|undefined>(undefined);
const draggedRef = useRef(false);
const transformRef = useRef<any>(undefined);

const highlightSelected = useCallback((svg: d3.Selection<SVGSVGElement, unknown, null, undefined>) => {
const highlightSelected = useCallback((svg: d3.Selection<any, unknown, null, undefined>) => {
if (animating || !selectedNodeId) {
return;
}
Expand All @@ -260,7 +264,7 @@ export const Graph = (props: Props) => {

// highlight selected node
svg
.selectAll("g")
.selectAll("g.node")
.selectAll("ellipse")
.style("opacity", unselectedOpacity)
.filter((d: any) => connectedNodeIds.includes(d.id))
Expand Down Expand Up @@ -305,7 +309,7 @@ export const Graph = (props: Props) => {

// highlight text
svg
.selectAll("g")
.selectAll("g.node")
.selectAll("text")
.style("opacity", unselectedOpacity)
.filter((d: any) => connectedNodeIds.includes(d.id))
Expand Down Expand Up @@ -373,6 +377,22 @@ export const Graph = (props: Props) => {
}
}, [d3Graph, graph]);

// enable zooming
useEffect(() => {
const svg = d3.select(svgRef.current);

// add zoom
const zoom = d3.zoom()
//.scaleExtent([0.5, 10]) // This defines the zoom levels (min, max)
.on("zoom", (e) => {
const root = svg.select("g.root");
root.attr("transform", e.transform);
transformRef.current = e.transform;
onTransformed?.(e.transform);
});
svg.call(zoom as any);
});

// draw the graph
useEffect(() => {
if (!svgRef.current) {
Expand All @@ -385,8 +405,15 @@ export const Graph = (props: Props) => {
// clear the existing items
svg.selectAll("*").remove();

// add the root element
const root = svg
.append("g")
.attr("class", "root")
.attr("transform", transformRef.current)
;

const addArrowMarker = (id: string, color: string, opacity?: number) => {
svg
root
.append("svg:defs")
.append("svg:marker")
.attr("id", id)
Expand Down Expand Up @@ -415,11 +442,12 @@ export const Graph = (props: Props) => {
addArrowMarker("unselectedLoopArrow", "black", unselectedOpacity / lineAndLoopOpacity); // 0

// draw nodes
const nodes = svg
.selectAll("g")
const nodes = root
.selectAll("g.node")
.data(d3Graph.nodes)
.enter()
.append("g");
.append("g")
.attr("class", "node");

const dragStart = (d: any) => {
simulation?.alphaTarget(0.5).restart();
Expand Down Expand Up @@ -565,7 +593,7 @@ export const Graph = (props: Props) => {
].join(" ");

// draw backgrounds for edges to increase click area
const lineBackgrounds = svg
const lineBackgrounds = root
.selectAll("line.edge-background")
.data(d3Graph.edges)
.enter()
Expand All @@ -585,7 +613,7 @@ export const Graph = (props: Props) => {
;

// draw edges
const lines = svg
const lines = root
.selectAll("line.edge")
.data(d3Graph.edges)
.enter()
Expand All @@ -609,7 +637,7 @@ export const Graph = (props: Props) => {

const loopStyle = drawingMode === "delete" ? "cursor: pointer" : "pointer-events: none";

const loopBackgrounds = svg
const loopBackgrounds = root
.selectAll("path.loop-background")
.data(d3Graph.nodes.filter(n => n.loops))
.enter()
Expand All @@ -626,7 +654,7 @@ export const Graph = (props: Props) => {
})
;

const loops = svg
const loops = root
.selectAll("path.loop")
.data(d3Graph.nodes.filter(n => n.loops))
.enter()
Expand All @@ -647,7 +675,7 @@ export const Graph = (props: Props) => {
const rubberBandNode = d3Graph.nodes.find(n => n.id === rubberBand?.from);
if (rubberBand && rubberBandNode) {
const data = [{x1: rubberBandNode.x, x2: rubberBand.to.x, y1: rubberBandNode.y, y2: rubberBand.to.y}];
svg
root
.selectAll("line.rubberband")
.data(data)
.enter()
Expand All @@ -664,7 +692,7 @@ export const Graph = (props: Props) => {

// add loopback "ghost" with background
if (!rubberBandNode.loops) {
svg
root
.selectAll("path.ghost-loop-background")
.data([rubberBandNode])
.enter()
Expand All @@ -680,7 +708,7 @@ export const Graph = (props: Props) => {
onNodeClick?.(rubberBandNode.id);
})
;
svg
root
.selectAll("path.ghost-loop")
.data([rubberBandNode])
.enter()
Expand All @@ -704,7 +732,8 @@ export const Graph = (props: Props) => {
highlightSelected(svg);

}, [svgRef, d3Graph, allowDragging, autoArrange, rubberBand, drawingMode,
onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop, setSelectedNodeId, selectedNodeId, highlightSelected]);
onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop, setSelectedNodeId,
selectedNodeId, highlightSelected]);

// animate the node if needed
useEffect(() => {
Expand All @@ -713,22 +742,23 @@ export const Graph = (props: Props) => {
}

const svg = d3.select(svgRef.current);
const root = svg.select("g.root");

// de-highlight all nodes
svg
.selectAll("g")
root
.selectAll("g.node")
.selectAll("ellipse")
.attr("fill", "#fff");

// highlight animated node
svg
.selectAll("g")
root
.selectAll("g.node")
.selectAll("ellipse")
.filter((d: any) => highlightNode?.id === d.id)
.attr("fill", animatedNodeColor);

// highlight animated edges
svg
root
.selectAll("line")
.attr("stroke", "#999")
.attr("stroke-dasharray", (d: any) => lineDashArray(d))
Expand All @@ -741,7 +771,7 @@ export const Graph = (props: Props) => {
.attr("stroke-dasharray", highlightAllNextNodes ? "4" : "")
.attr("marker-end", animatedArrowUrl);

svg
root
.selectAll("path.loop")
.attr("stroke", "#999")
.attr("stroke-dasharray", "")
Expand All @@ -751,7 +781,7 @@ export const Graph = (props: Props) => {
.attr("stroke-dasharray", highlightAllNextNodes ? "4" : "")
.attr("marker-end", animatedArrowUrl);

highlightSelected(svg);
highlightSelected(root);
}, [svgRef, d3Graph.nodes, selectedNodeId, highlightNode, highlightLoopOnNode,
highlightEdge, highlightAllNextNodes, highlightSelected]);

Expand Down

0 comments on commit ef46009

Please sign in to comment.