Skip to content

Commit

Permalink
Merge pull request #53 from concord-consortium/187382788-add-fit-and-…
Browse files Browse the repository at this point in the history
…recenter

feat: Add fit/center view [PT-187382788]
  • Loading branch information
dougmartin authored Apr 8, 2024
2 parents 5408b1a + d258b8e commit e042321
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 19 deletions.
65 changes: 63 additions & 2 deletions src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,60 @@ export const App = () => {
const animationInterval = useRef<number>();
const { graph, updateGraph, setGraph } = useGraph();
const [initialGraph, setInitialGraph] = useState<GraphData>();
const [fitViewAt, setFitViewAt] = useState<number>();
const [recenterViewAt, setRecenterViewAt] = useState<number>();
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();
const innerOutputRef = useRef<HTMLDivElement | null>(null);
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]);
Expand Down Expand Up @@ -419,6 +462,14 @@ export const App = () => {
}
};

const handleFitView = () => {
setFitViewAt(Date.now());
};

const handleRecenterView = () => {
setRecenterViewAt(Date.now());
};

if (loadState === "loading") {
return <div className="loading">Loading ...</div>;
}
Expand Down Expand Up @@ -479,6 +530,11 @@ export const App = () => {
setSelectedNodeId={setSelectedNodeId}
onReset={handleReset}
onReturnToMainMenu={handleReturnToMainMenu}
onFitView={handleFitView}
onRecenterView={handleRecenterView}
fitViewAt={fitViewAt}
recenterViewAt={recenterViewAt}
onDimensions={handleDimensionChange}
/>
:
<Dataset
Expand All @@ -493,6 +549,11 @@ export const App = () => {
setSelectedNodeId={setSelectedNodeId}
onReset={handleReset}
onReturnToMainMenu={handleReturnToMainMenu}
onFitView={handleFitView}
onRecenterView={handleRecenterView}
fitViewAt={fitViewAt}
recenterViewAt={recenterViewAt}
onDimensions={handleDimensionChange}
/>
}
</div>
Expand Down
16 changes: 14 additions & 2 deletions src/components/dataset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -36,6 +42,8 @@ export const Dataset = (props: Props) => {
onToolSelected={handleToolSelected}
onReset={onReset}
onReturnToMainMenu={onReturnToMainMenu}
onFitView={onFitView}
onRecenterView={onRecenterView}
/>
<div className="instructions">
<h2>Markov Chains</h2>
Expand All @@ -59,6 +67,8 @@ export const Dataset = (props: Props) => {
onToolSelected={handleToolSelected}
onReset={onReset}
onReturnToMainMenu={onReturnToMainMenu}
onFitView={onFitView}
onRecenterView={onRecenterView}
/>
<Graph
mode="dataset"
Expand All @@ -72,6 +82,8 @@ export const Dataset = (props: Props) => {
allowDragging={true && !animating}
autoArrange={true}
setSelectedNodeId={setSelectedNodeId}
fitViewAt={fitViewAt}
recenterViewAt={recenterViewAt}
/>
</div>
);
Expand Down
15 changes: 14 additions & 1 deletion src/components/drawing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,23 @@ interface Props {
graph: GraphData;
selectedNodeId?: string;
animating: boolean;
fitViewAt?: number;
recenterViewAt?: number;
setGraph: React.Dispatch<React.SetStateAction<GraphData>>;
setHighlightNode: React.Dispatch<React.SetStateAction<Node | undefined>>
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<DrawingMode>("select");
const [firstEdgeNode, setFirstEdgeNode] = useState<Node|undefined>(undefined);
const [rubberBand, setRubberBand] = useState<RubberBand|undefined>(undefined);
Expand All @@ -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) => {
Expand Down Expand Up @@ -263,6 +272,8 @@ export const Drawing = (props: Props) => {
onToolSelected={handleToolSelected}
onReset={onReset}
onReturnToMainMenu={onReturnToMainMenu}
onFitView={onFitView}
onRecenterView={onRecenterView}
/>
<Graph
mode="drawing"
Expand All @@ -285,6 +296,8 @@ export const Drawing = (props: Props) => {
setSelectedNodeId={setSelectedNodeId}
onDimensions={handleDimensionChange}
onTransformed={handleTransformed}
fitViewAt={fitViewAt}
recenterViewAt={recenterViewAt}
/>
<DragIcon drawingMode={drawingMode} />
<NodeModal
Expand Down
67 changes: 59 additions & 8 deletions src/components/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const unselectedLoopArrowUrl = "url(#unselectedLoopArrow)";

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

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

export type GraphSettings = {
minRadius: number;
Expand Down Expand Up @@ -55,6 +55,8 @@ type Props = {
drawingMode?: DrawingMode;
selectedNodeId?: string;
animating: boolean;
fitViewAt?: number;
recenterViewAt?: number;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onNodeClick?: (id: string, onLoop?: boolean) => void;
onNodeDoubleClick?: (id: string) => void;
Expand Down Expand Up @@ -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<SVGSVGElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -250,7 +253,10 @@ 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 transformRef = useRef<Transform>();
const zoomRef = useRef<d3.ZoomBehavior<any, any>>();
const lastFitViewAtRef = useRef<number>();
const lastRecenterViewAtRef = useRef<number>();

const highlightSelected = useCallback((svg: d3.Selection<any, unknown, null, undefined>) => {
if (animating || !selectedNodeId) {
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
if (!autoArrange && onClick) {
onClick(e);
Expand Down
13 changes: 10 additions & 3 deletions src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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) => {
Expand All @@ -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<Tool>("select");

const handleToolSelected = useCallback((tool: Tool) => {
Expand All @@ -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));
Expand Down
7 changes: 4 additions & 3 deletions src/hooks/use-codap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type OutputTextMode = "replace"|"append";
export type UseCODAPOptions = {
onCODAPDataChanged: OnCODAPDataChanged;
getGraph: GetGraphCallback;
setGraph: React.Dispatch<React.SetStateAction<GraphData>>
setGraph: (data: GraphData, version: number) => void;
setInitialGraph: React.Dispatch<React.SetStateAction<GraphData|undefined>>
};

Expand All @@ -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;
Expand Down Expand Up @@ -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}))});
}
Expand Down

0 comments on commit e042321

Please sign in to comment.