From 3a71dd65d317b3501ba4484892b9e83ce0cfdce0 Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Mon, 8 Apr 2024 12:13:33 -0400 Subject: [PATCH] feat: Add fit/center view [PT-187382788] This adds the fit and center view options. This also updates the zoom to use the d3 type for of the ZoomTransform class instead of a custom plain object type. --- src/components/app.tsx | 30 +++++++++++++++-- src/components/dataset.tsx | 15 +++++++-- src/components/drawing.tsx | 11 ++++++- src/components/graph.tsx | 67 +++++++++++++++++++++++++++++++++----- src/components/toolbar.tsx | 13 ++++++-- src/hooks/use-codap.ts | 2 +- 6 files changed, 121 insertions(+), 17 deletions(-) diff --git a/src/components/app.tsx b/src/components/app.tsx index 95e4a2c..14cc921 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -88,10 +88,20 @@ export const App = () => { const animationInterval = useRef(); const { graph, updateGraph, setGraph } = useGraph(); const [initialGraph, setInitialGraph] = useState(); + const [fitViewAt, setFitViewAt] = useState(); + const [recenterViewAt, setRecenterViewAt] = useState(); + const onCODAPDataChanged = (values: string[]) => { + updateGraph(values); + setFitViewAt(Date.now()); + }; + const onSetGraph = (data: GraphData) => { + setGraph(data); + setFitViewAt(Date.now()); + }; const { dragging, outputToDataset, viewMode, setViewMode, notifyStateIsDirty, loadState } = useCODAP({ - onCODAPDataChanged: updateGraph, + onCODAPDataChanged, getGraph: useCallback(() => graph, [graph]), - setGraph, + setGraph: onSetGraph, setInitialGraph }); const { generate } = useGenerator(); @@ -419,6 +429,14 @@ export const App = () => { } }; + const handleFitView = () => { + setFitViewAt(Date.now()); + }; + + const handleRecenterView = () => { + setRecenterViewAt(Date.now()); + }; + if (loadState === "loading") { return
Loading ...
; } @@ -479,6 +497,10 @@ export const App = () => { setSelectedNodeId={setSelectedNodeId} onReset={handleReset} onReturnToMainMenu={handleReturnToMainMenu} + onFitView={handleFitView} + onRecenterView={handleRecenterView} + fitViewAt={fitViewAt} + recenterViewAt={recenterViewAt} /> : { setSelectedNodeId={setSelectedNodeId} onReset={handleReset} onReturnToMainMenu={handleReturnToMainMenu} + onFitView={handleFitView} + onRecenterView={handleRecenterView} + fitViewAt={fitViewAt} + recenterViewAt={recenterViewAt} /> } diff --git a/src/components/dataset.tsx b/src/components/dataset.tsx index 3cffe0a..d54810e 100644 --- a/src/components/dataset.tsx +++ b/src/components/dataset.tsx @@ -14,15 +14,20 @@ interface Props { selectedNodeId?: string; animating: boolean; graphEmpty: boolean; + fitViewAt?: number; + recenterViewAt?: number; setSelectedNodeId: (id?: string, skipToggle?: boolean) => void; onReset: () => void; onReturnToMainMenu: () => void; + onFitView: () => void; + onRecenterView: () => void; } export const Dataset = (props: Props) => { const {highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, - graph, graphEmpty, setSelectedNodeId, selectedNodeId, animating, onReset, onReturnToMainMenu} = props; - + graph, graphEmpty, setSelectedNodeId, selectedNodeId, animating, + fitViewAt, recenterViewAt, + onReset, onReturnToMainMenu, onFitView, onRecenterView} = props; const handleToolSelected = (tool: Tool) => { // TBD @@ -36,6 +41,8 @@ export const Dataset = (props: Props) => { onToolSelected={handleToolSelected} onReset={onReset} onReturnToMainMenu={onReturnToMainMenu} + onFitView={onFitView} + onRecenterView={onRecenterView} />

Markov Chains

@@ -59,6 +66,8 @@ export const Dataset = (props: Props) => { onToolSelected={handleToolSelected} onReset={onReset} onReturnToMainMenu={onReturnToMainMenu} + onFitView={onFitView} + onRecenterView={onRecenterView} /> { allowDragging={true && !animating} autoArrange={true} setSelectedNodeId={setSelectedNodeId} + fitViewAt={fitViewAt} + recenterViewAt={recenterViewAt} />
); diff --git a/src/components/drawing.tsx b/src/components/drawing.tsx index 7ca093d..da75a8a 100644 --- a/src/components/drawing.tsx +++ b/src/components/drawing.tsx @@ -21,17 +21,22 @@ interface Props { graph: GraphData; selectedNodeId?: string; animating: boolean; + fitViewAt?: number; + recenterViewAt?: number; setGraph: React.Dispatch>; setHighlightNode: React.Dispatch> setSelectedNodeId: (id?: string, skipToggle?: boolean) => void; onReset: () => void; onReturnToMainMenu: () => void; + onFitView: () => void; + onRecenterView: () => void; } export const Drawing = (props: Props) => { const {highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, graph, setGraph, setHighlightNode, setSelectedNodeId: _setSelectedNodeId, - selectedNodeId, animating, onReset, onReturnToMainMenu} = props; + fitViewAt, recenterViewAt, + selectedNodeId, animating, onReset, onReturnToMainMenu, onFitView, onRecenterView} = props; const [drawingMode, setDrawingMode] = useState("select"); const [firstEdgeNode, setFirstEdgeNode] = useState(undefined); const [rubberBand, setRubberBand] = useState(undefined); @@ -263,6 +268,8 @@ export const Drawing = (props: Props) => { onToolSelected={handleToolSelected} onReset={onReset} onReturnToMainMenu={onReturnToMainMenu} + onFitView={onFitView} + onRecenterView={onRecenterView} /> { setSelectedNodeId={setSelectedNodeId} onDimensions={handleDimensionChange} onTransformed={handleTransformed} + fitViewAt={fitViewAt} + recenterViewAt={recenterViewAt} /> ) => void; onNodeClick?: (id: string, onLoop?: boolean) => void; onNodeDoubleClick?: (id: string) => void; @@ -240,6 +242,7 @@ export const Graph = (props: Props) => { const {graph, highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, allowDragging, autoArrange, rubberBand, drawingMode, onClick, onNodeClick, onNodeDoubleClick, onEdgeClick, onDragStop, + fitViewAt, recenterViewAt, selectedNodeId, setSelectedNodeId, animating, onDimensions, onTransformed} = props; const svgRef = useRef(null); const wrapperRef = useRef(null); @@ -250,7 +253,10 @@ export const Graph = (props: Props) => { const lastClickTimeRef = useRef(undefined); const lastClickIdRef = useRef(undefined); const draggedRef = useRef(false); - const transformRef = useRef(undefined); + const transformRef = useRef(); + const zoomRef = useRef>(); + const lastFitViewAtRef = useRef(); + const lastRecenterViewAtRef = useRef(); const highlightSelected = useCallback((svg: d3.Selection) => { if (animating || !selectedNodeId) { @@ -382,15 +388,18 @@ export const Graph = (props: Props) => { const svg = d3.select(svgRef.current); // add zoom - const zoom = d3.zoom() + zoomRef.current = 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); + const transform = e.transform as d3.ZoomTransform; + root.attr("transform", transform.toString()); + transformRef.current = transform; + onTransformed?.(transform); }); - svg.call(zoom as any); + svg + .call(zoomRef.current) + .on("dblclick.zoom", null); // disable double click to zoom }); // draw the graph @@ -409,7 +418,7 @@ export const Graph = (props: Props) => { const root = svg .append("g") .attr("class", "root") - .attr("transform", transformRef.current) + .attr("transform", transformRef.current?.toString() ?? "") ; const addArrowMarker = (id: string, color: string, opacity?: number) => { @@ -785,6 +794,48 @@ export const Graph = (props: Props) => { }, [svgRef, d3Graph.nodes, selectedNodeId, highlightNode, highlightLoopOnNode, highlightEdge, highlightAllNextNodes, highlightSelected]); + const fitOrCenter = useCallback((op: "fit" | "center") => { + if (!width || !height || !zoomRef.current) { + return false; + } + + const svg = d3.select(svgRef.current); + const root = svg.select("g.root"); + const bounds = (root.node() as SVGGElement).getBBox(); + const center = { + x: bounds.x + (bounds.width / 2), + y: bounds.y + (bounds.height / 2), + }; + const currentScale = transformRef.current?.k ?? 1; + const fitScale = Math.min(width / bounds.width, height / bounds.height) * 0.8; + const scale = op === "center" ? currentScale : fitScale; + const x = scale * (-center.x / 2); + const y = scale * (-center.y / 2); + const transform = new d3.ZoomTransform(scale, x, y); + + svg.call(zoomRef.current.transform, transform); + + return true; + }, [width, height]); + + // listen for fit view requests + useEffect(() => { + if ((fitViewAt ?? 0) > (lastFitViewAtRef.current ?? 0)) { + if (fitOrCenter("fit")) { + lastFitViewAtRef.current = fitViewAt; + } + } + }, [fitViewAt, fitOrCenter]); + + // listen for recenter view requests + useEffect(() => { + if ((recenterViewAt ?? 0) > (lastRecenterViewAtRef.current ?? 0)) { + if (fitOrCenter("center")) { + lastRecenterViewAtRef.current = recenterViewAt; + } + } + }, [recenterViewAt, fitOrCenter]); + const handleClick = useCallback((e: React.MouseEvent) => { if (!autoArrange && onClick) { onClick(e); diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index af76a23..e55fb36 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -16,7 +16,7 @@ import "./toolbar.scss"; export const allTools = ["select","addNode","addEdge","addText","delete","fitView","recenter","reset","home"] as const; const toggleableTools: Tool[] = ["select","addNode","addEdge","addText","delete"]; const nonTopTools: Tool[] = ["reset","home"]; -const notImplementedTools: Tool[] = ["addText","fitView","recenter"]; +const notImplementedTools: Tool[] = ["addText"]; export type Tool = typeof allTools[number]; @@ -55,6 +55,8 @@ interface ToolbarProps { onToolSelected: (tool: Tool) => void; onReset: () => void; onReturnToMainMenu: () => void; + onFitView: () => void; + onRecenterView: () => void; } export const ToolbarButton = ({tool, selectedTool, onClick}: ToolbarButtonProps) => { @@ -76,7 +78,8 @@ export const ToolbarButton = ({tool, selectedTool, onClick}: ToolbarButtonProps) ); }; -export const Toolbar = ({tools, onToolSelected, onReset, onReturnToMainMenu}: ToolbarProps) => { +export const Toolbar = (props: ToolbarProps) => { + const {tools, onToolSelected, onReset, onReturnToMainMenu, onFitView, onRecenterView} = props; const [selectedTool, setSelectedTool] = useState("select"); const handleToolSelected = useCallback((tool: Tool) => { @@ -90,9 +93,13 @@ export const Toolbar = ({tools, onToolSelected, onReset, onReturnToMainMenu}: To onReset(); } else if (tool === "home") { onReturnToMainMenu(); + } else if (tool === "fitView") { + onFitView(); + } else if (tool === "recenter") { + onRecenterView(); } onToolSelected(tool); - }, [selectedTool, onReset, onReturnToMainMenu, onToolSelected]); + }, [selectedTool, onReset, onReturnToMainMenu, onFitView, onRecenterView, onToolSelected]); const topTools = tools.filter(tool => !nonTopTools.includes(tool)); const bottomTools = tools.filter(tool => nonTopTools.includes(tool)); diff --git a/src/hooks/use-codap.ts b/src/hooks/use-codap.ts index 57ca5e9..00c6781 100644 --- a/src/hooks/use-codap.ts +++ b/src/hooks/use-codap.ts @@ -33,7 +33,7 @@ export type OutputTextMode = "replace"|"append"; export type UseCODAPOptions = { onCODAPDataChanged: OnCODAPDataChanged; getGraph: GetGraphCallback; - setGraph: React.Dispatch> + setGraph: (data: GraphData) => void; setInitialGraph: React.Dispatch> };