diff --git a/src/components/app.tsx b/src/components/app.tsx index 95e4a2c..f97195a 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -88,10 +88,48 @@ 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 widthRef = useRef(0); + const heightRef = useRef(0); + const onSetGraph = (data: GraphData, version: number) => { + + const done = () => { + setGraph(data); + setFitViewAt(Date.now()); + }; + + const translateOrigin = () => { + if (!widthRef.current || !heightRef.current) { + setTimeout(translateOrigin, 1); + } else { + // the original data was stored with the origin at the top left + // but now data is stored with the origin in the center + // so we need to translate the points on load to the center + const xOffset = -widthRef.current / 2; + const yOffset = -heightRef.current / 2; + data.nodes.forEach(n => { + n.x = (n.x ?? 0) + xOffset; + n.y = (n.y ?? 0) + yOffset; + }); + done(); + } + }; + + if (version < 2) { + translateOrigin(); + } else { + done(); + } + }; const { dragging, outputToDataset, viewMode, setViewMode, notifyStateIsDirty, loadState } = useCODAP({ - onCODAPDataChanged: updateGraph, + onCODAPDataChanged, getGraph: useCallback(() => graph, [graph]), - setGraph, + setGraph: onSetGraph, setInitialGraph }); const { generate } = useGenerator(); @@ -99,6 +137,11 @@ export const App = () => { const [fastSimulation, setFastSimulation] = useState(false); const fastSimulationRef = useRef(false); + const handleDimensionChange = ({width, height}: {width: number, height: number}) => { + widthRef.current = width; + heightRef.current = height; + }; + const animating = useMemo(() => { return generationMode !== "ready"; }, [generationMode]); @@ -419,6 +462,14 @@ export const App = () => { } }; + const handleFitView = () => { + setFitViewAt(Date.now()); + }; + + const handleRecenterView = () => { + setRecenterViewAt(Date.now()); + }; + if (loadState === "loading") { return
Loading ...
; } @@ -479,6 +530,11 @@ export const App = () => { setSelectedNodeId={setSelectedNodeId} onReset={handleReset} onReturnToMainMenu={handleReturnToMainMenu} + onFitView={handleFitView} + onRecenterView={handleRecenterView} + fitViewAt={fitViewAt} + recenterViewAt={recenterViewAt} + onDimensions={handleDimensionChange} /> : { setSelectedNodeId={setSelectedNodeId} onReset={handleReset} onReturnToMainMenu={handleReturnToMainMenu} + onFitView={handleFitView} + onRecenterView={handleRecenterView} + fitViewAt={fitViewAt} + recenterViewAt={recenterViewAt} + onDimensions={handleDimensionChange} /> } diff --git a/src/components/dataset.tsx b/src/components/dataset.tsx index 3cffe0a..80f8769 100644 --- a/src/components/dataset.tsx +++ b/src/components/dataset.tsx @@ -14,15 +14,21 @@ 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; + onDimensions?: (dimensions: {width: number, height: number}) => 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 +42,8 @@ export const Dataset = (props: Props) => { onToolSelected={handleToolSelected} onReset={onReset} onReturnToMainMenu={onReturnToMainMenu} + onFitView={onFitView} + onRecenterView={onRecenterView} />

Markov Chains

@@ -59,6 +67,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..19c4a66 100644 --- a/src/components/drawing.tsx +++ b/src/components/drawing.tsx @@ -21,17 +21,23 @@ 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; + onDimensions?: (dimensions: {width: number, height: number}) => 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); @@ -49,6 +55,9 @@ export const Drawing = (props: Props) => { const handleDimensionChange = ({width, height}: {width: number, height: number}) => { widthRef.current = width; heightRef.current = height; + + // also tell the app so that it can translate the origin of any loaded data if needed + props.onDimensions?.({width, height}); }; const handleTransformed = (transform: Transform) => { @@ -263,6 +272,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..fbceed9 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, version: number) => void; setInitialGraph: React.Dispatch> }; @@ -58,6 +58,7 @@ export const useCODAP = ({onCODAPDataChanged, getGraph, setGraph, setInitialGrap const {nodes, edges} = getGraph(); state.values.nodes = nodes; state.values.edges = edges; + state.values.version = 2; // there was no version 1 but set graph returns 1 if there is no version } return state; @@ -87,9 +88,9 @@ export const useCODAP = ({onCODAPDataChanged, getGraph, setGraph, setInitialGrap setViewMode(values.viewMode); if (values.viewMode === "drawing") { - const {nodes, edges} = values; + const {nodes, edges, version} = values; if (nodes !== undefined && edges !== undefined) { - setGraph({nodes, edges}); + setGraph({nodes, edges}, version ?? 1); // save a copy of the graph setInitialGraph({nodes: nodes.map((n: Node) => ({...n})), edges: edges.map((e: Edge) => ({...e}))}); }