Skip to content

feat: add d3-force to push away overlapped pods #245

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"@emotion/styled": "^11.10.5",
"@mui/icons-material": "^5.10.9",
"@mui/material": "^5.10.10",
"@reactflow/node-resizer": "^2.0.1",
"@reactflow/node-resizer": "^2.1.0",
"@remirror/extension-react-tables": "^2.2.9",
"@remirror/extension-sub": "^2.0.9",
"@remirror/extension-text-highlight": "^2.0.10",
Expand All @@ -26,6 +26,7 @@
"@types/react-dom": "^18.0.0",
"ansi-to-react": "^6.1.6",
"crypto-js": "^4.1.1",
"d3-force": "^3.0.0",
"formik": "^2.2.9",
"graphql": "^16.6.0",
"jwt-decode": "^3.1.2",
Expand All @@ -44,7 +45,7 @@
"react-resizable": "^3.0.4",
"react-router-dom": "^6.4.2",
"react-scripts": "5.0.1",
"reactflow": "^11.5.6",
"reactflow": "^11.7.0",
"remirror": "^2.0.21",
"slate": "^0.82.1",
"slate-history": "^0.66.0",
Expand Down
17 changes: 17 additions & 0 deletions ui/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ReactFlow, {
applyEdgeChanges,
applyNodeChanges,
Background,
BackgroundVariant,
MiniMap,
Controls,
Handle,
Expand Down Expand Up @@ -496,6 +497,7 @@ function CanvasImpl() {
(state) => state.removeDragHighlight
);
const updateView = useStore(store, (state) => state.updateView);
const autoForceGlobal = useStore(store, (state) => state.autoForceGlobal);

const addNode = useStore(store, (state) => state.addNode);
const reactFlowInstance = useReactFlow();
Expand Down Expand Up @@ -602,6 +604,8 @@ function CanvasImpl() {
}
// update view manually to remove the drag highlight.
updateView();
// TODO run auto layout
// autoForceGlobal();
}}
onNodeDrag={(event, node) => {
let mousePos = project({ x: event.clientX, y: event.clientY });
Expand Down Expand Up @@ -665,6 +669,19 @@ function CanvasImpl() {
<Controls showInteractive={!isGuest} />

<Background />
<Background
id="1"
gap={10}
color="#f1f1f1"
variant={BackgroundVariant.Lines}
/>
<Background
id="2"
gap={100}
offset={1}
color="#ccc"
variant={BackgroundVariant.Lines}
/>
</Box>
</ReactFlow>
{showContextMenu && (
Expand Down
9 changes: 9 additions & 0 deletions ui/src/components/MyKBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ export function MyKBar() {
const store = useContext(RepoContext);
if (!store) throw new Error("Missing BearContext.Provider in the tree");
const autoLayout = useStore(store, (state) => state.autoLayout);
const autoForceGlobal = useStore(store, (state) => state.autoForceGlobal);
const actions = [
{
id: "auto-force",
name: "Auto Force",
keywords: "auto force",
perform: () => {
autoForceGlobal();
},
},
{
id: "auto-layout",
name: "Auto Layout",
Expand Down
31 changes: 31 additions & 0 deletions ui/src/components/nodes/Scope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import DeleteIcon from "@mui/icons-material/Delete";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
import ViewTimelineOutlinedIcon from "@mui/icons-material/ViewTimelineOutlined";
import CompressIcon from "@mui/icons-material/Compress";
import Moveable from "react-moveable";

import { useStore } from "zustand";
Expand Down Expand Up @@ -81,6 +83,8 @@ function MyFloatingToolbar({ id }: { id: string }) {
},
[onCopy, cutBegin, id]
);
const autoLayout = useStore(store, (state) => state.autoLayout);
const autoForce = useStore(store, (state) => state.autoForce);
return (
<Box>
{!isGuest && (
Expand All @@ -95,6 +99,33 @@ function MyFloatingToolbar({ id }: { id: string }) {
</IconButton>
</Tooltip>
)}
{/* auto force layout */}
{!isGuest && (
<Tooltip title="force layout">
<IconButton
size="small"
onClick={() => {
autoForce(id);
}}
>
<ViewTimelineOutlinedIcon fontSize="inherit" />
</IconButton>
</Tooltip>
)}
{/* auto layout (auto shrink scopes) */}
{/* {!isGuest && (
<Tooltip title="auto shrink (global)">
<IconButton
size="small"
onClick={() => {
autoLayout();
}}
>
<CompressIcon fontSize="inherit" />
</IconButton>
</Tooltip>
)} */}
{/* copy to clipbooard */}
<CopyToClipboard
text="dummy"
options={{ debug: true, format: "text/plain", onCopy } as any}
Expand Down
173 changes: 169 additions & 4 deletions ui/src/lib/store/canvasSlice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ import { Transaction, YEvent } from "yjs";

import { match, P } from "ts-pattern";

import {
forceSimulation,
forceLink,
forceManyBody,
forceCollide,
forceCenter,
forceX,
forceY,
SimulationNodeDatum,
SimulationLinkDatum,
} from "d3-force";

import { myNanoId } from "../utils";

import {
Expand Down Expand Up @@ -278,6 +290,8 @@ export interface CanvasSlice {
node2children: Map<string, string[]>;
buildNode2Children: () => void;
autoLayout: () => void;
autoForce: (scopeId: string) => void;
autoForceGlobal: () => void;
}

export const createCanvasSlice: StateCreator<MyState, [], [], CanvasSlice> = (
Expand Down Expand Up @@ -896,6 +910,156 @@ export const createCanvasSlice: StateCreator<MyState, [], [], CanvasSlice> = (
// update the view
get().updateView();
},
autoForceGlobal: () => {
// get all scopes,
let nodesMap = get().ydoc.getMap<Node>("pods");
let nodes: Node[] = Array.from(nodesMap.values());
get().buildNode2Children();
nodes
// sort the children so that the inner scope gets processed first.
.sort((a: Node, b: Node) => b.data.level - a.data.level)
.forEach((node) => {
if (node.type === "SCOPE") {
get().autoForce(node.id);
}
});
// Applying on ROOT scope is not ideal.
get().autoForce("ROOT");
},
/**
* Use d3-force to auto layout the nodes.
*/
autoForce: (scopeId) => {
// 1. get all the nodes and edges in the scope
// 2. get
type NodeType = {
id: string;
x: number;
y: number;
r: number;
width: number | null | undefined;
height: number | null | undefined;
};
const nodes = get().nodes.filter(
(node) => node.parentNode === (scopeId === "ROOT" ? undefined : scopeId)
);
if (nodes.length == 0) return;
const edges = get().edges;
const tmpNodes: NodeType[] = nodes.map((node) => ({
id: node.id,
x: node.position.x + (node.width! + node.height!) / 2,
y: node.position.y + (node.width! + node.height!) / 2,
r: (node.width! + node.height!) / 2,
width: node.width,
height: node.height,
}));
const tmpEdges = edges.map((edge) => ({
source: edge.source,
source0: 0,
target: edge.target,
target0: 1,
}));
const nodesMap = get().ydoc.getMap<Node>("pods");
// 2. construct a D3 tree for the nodes and their connections
// initialize the tree layout (see https://observablehq.com/@d3/tree for examples)
// const hierarchy = stratify<Node>()
// .id((d) => d.id)
// // get the id of each node by searching through the edges
// // this only works if every node has one connection
// .parentId((d: Node) => edges.find((e: Edge) => e.target === d.id)?.source)(
// nodes
// );
const simulation = forceSimulation<NodeType>(tmpNodes)
// .force(
// "link",
// forceLink(tmpEdges)
// .id((d: any) => d.id)
// .distance(20)
// .strength(0.5)
// )
// .force("charge", forceManyBody().strength(-1000))
// .force("x", forceX())
// .force("y", forceY())
.force(
"collide",
forceCollide().radius((d: any) => d.r)
)
// .force("link", d3.forceLink(edges).id(d => d.id))
// .force("charge", d3.forceManyBody())
// .force("center", forceCenter(0, 0))
.stop();
simulation.tick(3000);
tmpNodes.forEach((node) => {
node.x -= (node.width! + node.height!) / 2;
node.y -= (node.width! + node.height!) / 2;
});
// The nodes will all have new positions now. I'll need to make the graph to be top-left, i.e., the leftmost is 20, the topmost is 20.
// get the min x and y
let x1s = tmpNodes.map((node) => node.x);
let minx = Math.min(...x1s);
let y1s = tmpNodes.map((node) => node.y);
let miny = Math.min(...y1s);
// calculate the offset, leave 50 padding for the scope.
const offsetx = 50 - minx;
const offsety = 50 - miny;
// move the nodes
tmpNodes.forEach((node) => {
node.x += offsetx;
node.y += offsety;
});
// Apply the new positions
// TODO need to transform the nodes to the center of the scope.
tmpNodes.forEach(({ id, x, y }) => {
// FIXME I should assert here.
if (nodesMap.has(id)) {
nodesMap.set(id, {
...nodesMap.get(id)!,
// position: { x: x + scope!.position!.x, y: y + scope!.position!.y },
position: { x, y },
});
}
});

if (scopeId !== "ROOT") {
// update the scope's size to enclose all the nodes
x1s = tmpNodes.map((node) => node.x);
minx = Math.min(...x1s);
y1s = tmpNodes.map((node) => node.y);
miny = Math.min(...y1s);
const x2s = tmpNodes.map((node) => node.x + node.width!);
const maxx = Math.max(...x2s);
const y2s = tmpNodes.map((node) => node.y + node.height!);
const maxy = Math.max(...y2s);
const scope = nodesMap.get(scopeId);
nodesMap.set(scopeId, {
...scope!,
width: maxx - minx + 100,
height: maxy - minx + 100,
style: {
...scope!.style,
width: maxx - minx + 100,
height: maxy - minx + 100,
},
});
}

// trigger update to the db
// get the most recent nodes
let newNodes = Array.from(nodesMap.values());
newNodes.forEach((node) => {
// trigger update to the DB
let geoData = {
parent: node.parentNode ? node.parentNode : "ROOT",
x: node.position.x,
y: node.position.y,
width: node.width!,
height: node.height!,
};
get().setPodGeo(node.id, geoData, true);
});

get().updateView();
},
});

/**
Expand Down Expand Up @@ -933,10 +1097,11 @@ function fitChildren(
let width = maxx - minx;
let height = maxy - miny;
return {
x: minx - 10,
y: miny - 10,
width: width + 20,
height: height + 20,
// leave a 50 padding for the scope.
x: minx - 50,
y: miny - 50,
width: width + 100,
height: height + 100,
};
}

Expand Down
Loading