Skip to content
This repository has been archived by the owner on Jul 15, 2024. It is now read-only.

Add auto-layout using d3 forces simulation #23

Merged
merged 25 commits into from
Dec 13, 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
8 changes: 5 additions & 3 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@l2beat/discovery-types": "0.4.1",
"@total-typescript/ts-reset": "^0.3.7",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"classnames": "^2.3.2",
"d3-force": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^3.20.2",
"zustand": "^4.3.2",
"@total-typescript/ts-reset": "^0.3.7",
"@l2beat/discovery-types": "0.4.1"
"zustand": "^4.3.2"
},
"devDependencies": {
"@types/d3-force": "^3.0.9",
"@vitejs/plugin-react-swc": "^3.0.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
Expand Down
6 changes: 4 additions & 2 deletions packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
decodeNodeLocations,
getLayoutStorageKey,
} from './store/utils/storageParsing'
import { AutoLayoutButton } from './view/AutoLayoutButton'
import { Sidebar } from './view/Sidebar'
import { Viewport } from './view/Viewport'

Expand Down Expand Up @@ -151,7 +152,7 @@ export function App() {
<div>
<button
className={cx(
'rounded bg-blue-500 py-2 px-4 font-bold text-white',
'rounded bg-blue-500 px-4 py-2 font-bold text-white',
!loading.global && 'hover:bg-blue-700',
)}
type="button"
Expand All @@ -163,7 +164,8 @@ export function App() {
</button>
</div>

<div>
<div className="flex items-center">
<AutoLayoutButton />
<button
className="px-1 text-2xl hover:bg-gray-300"
type="button"
Expand Down
10 changes: 5 additions & 5 deletions packages/frontend/src/store/actions/updateNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export function updateNodeLocations(
): Partial<State> {
const movedNodes = state.nodes.map((n) => ({
...n,
box: locations[n.simpleNode.id] ?? n.box,
box: {
...n.box,
adamiak marked this conversation as resolved.
Show resolved Hide resolved
...locations[n.simpleNode.id],
},
}))

return updateNodePositions({
Expand All @@ -61,10 +64,7 @@ export function updateNodeLocations(
})
}

function getNodeBoxFromStorage(
projectId: string,
node: SimpleNode,
): Node['box'] | undefined {
function getNodeBoxFromStorage(projectId: string, node: SimpleNode) {
const storage = localStorage.getItem(getLayoutStorageKey(projectId))
if (storage === null) {
return undefined
Expand Down
4 changes: 2 additions & 2 deletions packages/frontend/src/store/utils/storageParsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ const NodeLocations = z.record(
z.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
width: z.number().optional(),
adamiak marked this conversation as resolved.
Show resolved Hide resolved
height: z.number().optional(),
}),
)

Expand Down
101 changes: 101 additions & 0 deletions packages/frontend/src/view/AutoLayoutButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
forceCenter,
forceLink,
forceManyBody,
forceSimulation,
SimulationLinkDatum,
SimulationNodeDatum,
} from 'd3-force'
import { useEffect, useState } from 'react'

import { Node } from '../store/State'
import { useStore } from '../store/store'
import { NodeLocations } from '../store/utils/storageParsing'

// d3 assumes each node is a single point (no width and height),
// so we scale the coordinates of the simulation to move the nodes
// further apart and not overlap
const SIM_SCALE = 10

interface SimulationNode extends SimulationNodeDatum {
id: string
x: number
y: number
node: Node
}

interface SimulationLink extends SimulationLinkDatum<SimulationNode> {
source: string
target: string
}

export function AutoLayoutButton() {
const nodes = useStore((state) => state.nodes)
const updateNodeLocations = useStore((state) => state.updateNodeLocations)
const [updatingLayout, setUpdatingLayout] = useState<boolean>(false)

const draw = () => {
if (!updatingLayout) return

const simNodes: SimulationNode[] = nodes.map((node) => ({
id: node.simpleNode.id,
x: node.box.x / SIM_SCALE,
adamiak marked this conversation as resolved.
Show resolved Hide resolved
y: node.box.y / SIM_SCALE,
node,
}))

const links = nodes
.map((n) => n.simpleNode)
.flatMap((n) =>
n.fields.map((f) => ({
source: n.id,
target: f.connection,
})),
)
.filter((l) => l.target !== undefined)
.filter(
(l) => simNodes.find((sn) => sn.id === l.target) !== undefined,
) as SimulationLink[]

const simulation = forceSimulation(simNodes)
.force(
'link',
forceLink(links).id((d) => (d as SimulationNode).id),
)
.force('charge', forceManyBody())
.force('center', forceCenter(0, 0))
.on('tick', ticked)
.on('end', ended)

function ticked() {
const nodeLocations: NodeLocations = {}
simNodes.forEach((simNode) => {
nodeLocations[simNode.id] = {
x: simNode.x * SIM_SCALE,
y: simNode.y * SIM_SCALE,
}
})
updateNodeLocations(nodeLocations)
}

function ended() {
simulation.stop()
setUpdatingLayout(false)
}
}

useEffect(() => {
draw()
}, [updatingLayout])

return (
<button
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
type="button"
disabled={updatingLayout}
onClick={() => setUpdatingLayout(true)}
>
{updatingLayout ? 'wait...' : 'Auto-layout'}
</button>
)
}
2 changes: 1 addition & 1 deletion packages/frontend/src/view/ScalableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const ScalableView = forwardRef(
props.transform.scale,
}}
>
<div className="pointer-events-none absolute top-[-220%] left-[-220%] h-[440%] w-[440%] bg-[url(/grid.svg)] bg-center" />
<div className="pointer-events-none absolute left-[-220%] top-[-220%] h-[440%] w-[440%] bg-[url(/grid.svg)] bg-center" />
</div>

{props.children}
Expand Down
6 changes: 3 additions & 3 deletions packages/frontend/src/view/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function SidebarForSingleNode({
<div>Click on the "🔍" to discover</div>{' '}
<p>
<button
className="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
type="button"
onClick={() => onDeleteNodes([node.id])}
>
Expand Down Expand Up @@ -82,7 +82,7 @@ function SidebarForSingleNode({

<p>
<button
className="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
type="button"
onClick={() => onDeleteNodes([node.id])}
>
Expand All @@ -102,7 +102,7 @@ function SidebarForMultipleNodes({
Selected <span className="font-bold">{selectedNodes.length}</span> nodes
<p>
<button
className="rounded bg-blue-500 py-2 px-4 font-bold text-white hover:bg-blue-700"
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
type="button"
onClick={() => onDeleteNode(selectedNodes.map((n) => n.id))}
>
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"module": "CommonJS",
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
Expand Down
29 changes: 29 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,11 @@
"@types/keygrip" "*"
"@types/node" "*"

"@types/d3-force@^3.0.9":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.9.tgz#dd96ccefba4386fe4ff36b8e4ee4e120c21fcf29"
integrity sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==

"@types/etag@*":
version "1.8.1"
resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.1.tgz#593ca8ddb43acb3db049bd0955fd64d281ab58b9"
Expand Down Expand Up @@ -1697,6 +1702,30 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==

"d3-dispatch@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==

d3-force@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==
dependencies:
d3-dispatch "1 - 3"
d3-quadtree "1 - 3"
d3-timer "1 - 3"

"d3-quadtree@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==

"d3-timer@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==

[email protected], debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
Expand Down
Loading