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

Adding force-simulation layout algorithm to draw nodes #119

Merged
merged 9 commits into from
Jun 22, 2022
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
"bn.js": "^4.11.9",
"combine-reducers": "^1.0.0",
"cytoscape": "^3.21.0",
"cytoscape-fcose": "^2.1.0",
"cytoscape-klay": "^3.1.4",
"cytoscape-no-overlap": "^1.0.1",
"cytoscape-popper": "^2.0.0",
"date-fns": "^2.9.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,38 +75,22 @@ export default class ElementsBuilder {
}

build(customLayoutNodes?: CustomLayoutNodes): ElementDefinition[] {
let edges = addClassWithMoreThanOneBidirectional(this._edges, this._countEdgeDirection)
edges = addClassWithKind(edges)
if (!customLayoutNodes) {
return this._buildCoseLayout()
} else {
const edges = addClassWithMoreThanOneBidirectional(this._edges, this._countEdgeDirection)
const { center, nodes } = customLayoutNodes
return [center, ...nodes, ...addClassWithKind(edges)]
return this._buildLayout(edges)
}

const { center, nodes } = customLayoutNodes
return [center, ...nodes, ...edges]
}

_buildCoseLayout(): ElementDefinition[] {
_buildLayout(edges: ElementDefinition[]): ElementDefinition[] {
if (!this._center) {
throw new Error('Center node is required')
}
const center = {
...this._center,
position: { x: 0, y: 0 },
}
const nTypes = this._countNodeTypes.size

const r = this._SIZE / nTypes - 100 // get radio

const nodes = this._nodes.map((node: ElementDefinition, index: number) => {
return {
...node,
position: {
x: r * Math.cos((nTypes * Math.PI * index) / this._nodes.length),
y: r * Math.sin((nTypes * Math.PI * index) / this._nodes.length),
},
}
})

return [center, ...nodes, ...this._edges]
return [this._center, ...this._nodes, ...edges]
}

getById(id: string): ElementDefinition | undefined {
Expand Down Expand Up @@ -222,7 +206,7 @@ function addClassWithMoreThanOneBidirectional(
function addClassWithKind(edges: ElementDefinition[]): ElementDefinition[] {
return edges.map((_edge) => {
const CLASS_NAME = _edge.data.kind
_edge.classes = _edge.classes ? `${_edge.classes},${CLASS_NAME}` : CLASS_NAME
_edge.classes = _edge.classes ? `${_edge.classes} ${CLASS_NAME}` : CLASS_NAME
return _edge
})
}
120 changes: 83 additions & 37 deletions src/apps/explorer/components/TransanctionBatchGraph/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import Cytoscape, {
ElementDefinition,
NodeSingular,
NodeDataDefinition,
EdgeDataDefinition,
EventObject,
} from 'cytoscape'
import Cytoscape, { ElementDefinition, NodeDataDefinition, EdgeDataDefinition, EventObject } from 'cytoscape'
import popper from 'cytoscape-popper'
import noOverlap from 'cytoscape-no-overlap'
import fcose from 'cytoscape-fcose'
import klay from 'cytoscape-klay'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import CytoscapeComponent from 'react-cytoscapejs'
import styled, { useTheme } from 'styled-components'
import BigNumber from 'bignumber.js'
import { OrderKind } from '@gnosis.pm/gp-v2-contracts'
import { faRedo } from '@fortawesome/free-solid-svg-icons'
import {
faRedo,
faDiceOne,
faDiceTwo,
faDiceThree,
faDiceFour,
faDiceFive,
IconDefinition,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'

import { GetTxBatchTradesResult as TxBatchData, Settlement as TxSettlement } from 'hooks/useTxBatchTrades'
Expand All @@ -23,15 +27,19 @@ import ElementsBuilder, { buildGridLayout } from 'apps/explorer/components/Trans
import { TypeEdgeOnTx, TypeNodeOnTx } from './types'
import { APP_NAME } from 'const'
import { HEIGHT_HEADER_FOOTER, TOKEN_SYMBOL_UNKNOWN } from 'apps/explorer/const'
import { STYLESHEET, ResetButton } from './styled'
import { STYLESHEET, ResetButton, LayoutButton, DropdownWrapper, FloatingWrapper } from './styled'
import { abbreviateString, FormatAmountPrecision, formattingAmountPrecision } from 'utils'
import CowLoading from 'components/common/CowLoading'
import { media } from 'theme/styles/media'
import { EmptyItemWrapper } from 'components/common/StyledUserDetailsTable'
import useWindowSizes from 'hooks/useWindowSizes'
import { layouts, LayoutNames } from './layouts'
import { DropdownOption, DropdownPosition } from 'apps/explorer/components/common/Dropdown'

Cytoscape.use(popper)
Cytoscape.use(noOverlap)
Cytoscape.use(fcose)
Cytoscape.use(klay)

const PROTOCOL_NAME = APP_NAME
const WrapperCytoscape = styled(CytoscapeComponent)`
Expand All @@ -43,6 +51,7 @@ const WrapperCytoscape = styled(CytoscapeComponent)`
margin: 1.6rem 0;
}
`
const iconDice = [faDiceOne, faDiceTwo, faDiceThree, faDiceFour, faDiceFive]

function getTypeNode(account: Account): TypeNodeOnTx {
let type = TypeNodeOnTx.Dex
Expand Down Expand Up @@ -76,7 +85,12 @@ function getNetworkParentNode(account: Account, networkName: string): string | u
return account.alias !== ALIAS_TRADER_NAME ? networkName : undefined
}

function getNodes(txSettlement: TxSettlement, networkId: Network, heightSize: number): ElementDefinition[] {
function getNodes(
txSettlement: TxSettlement,
networkId: Network,
heightSize: number,
layout: string,
): ElementDefinition[] {
if (!txSettlement.accounts) return []

const networkName = networkOptions.find((network) => network.id === networkId)?.name
Expand Down Expand Up @@ -122,7 +136,9 @@ function getNodes(txSettlement: TxSettlement, networkId: Network, heightSize: nu
})

return builder.build(
buildGridLayout(builder._countNodeTypes as Map<TypeNodeOnTx, number>, builder._center, builder._nodes),
layout === 'grid'
? buildGridLayout(builder._countNodeTypes as Map<TypeNodeOnTx, number>, builder._center, builder._nodes)
: undefined,
)
}

Expand Down Expand Up @@ -207,18 +223,27 @@ interface GraphBatchTxParams {
networkId: Network | undefined
}

function getLayout(): Cytoscape.LayoutOptions {
return {
name: 'grid',
position: function (node: NodeSingular): { row: number; col: number } {
return { row: node.data('row'), col: node.data('col') }
},
fit: true, // whether to fit the viewport to the graph
padding: 10, // padding used on fit
avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space
avoidOverlapPadding: 10, // extra spacing around nodes when avoidOverlap: true
nodeDimensionsIncludeLabels: false,
}
function DropdownButtonContent({
layout,
icon,
open,
}: {
layout: string
icon: IconDefinition
open?: boolean
}): JSX.Element {
return (
<>
<FontAwesomeIcon icon={icon} />
<span>Layout: {layout}</span>
<span className={`arrow ${open && 'open'}`} />
</>
)
}

const updateLayout = (cy: Cytoscape.Core, layoutName: string, noAnimation = false): void => {
cy.layout(noAnimation ? { ...layouts[layoutName], animate: false } : layouts[layoutName]).run()
cy.fit()
}

function TransanctionBatchGraph({
Expand All @@ -229,32 +254,34 @@ function TransanctionBatchGraph({
const cytoscapeRef = useRef<Cytoscape.Core | null>(null)
const cyPopperRef = useRef<PopperInstance | null>(null)
const [resetZoom, setResetZoom] = useState<boolean | null>(null)
const [layout, setLayout] = useState(layouts.grid)
const theme = useTheme()
const { innerHeight } = useWindowSizes()
const heightSize = innerHeight && innerHeight - HEIGHT_HEADER_FOOTER
const currentLayoutIndex = Object.keys(LayoutNames).findIndex((nameLayout) => nameLayout === layout.name)

const setCytoscape = useCallback(
(ref: Cytoscape.Core) => {
cytoscapeRef.current = ref
const updateLayout = (): void => {
ref.layout(getLayout()).run()
ref.fit()
}
ref.removeListener('resize')
ref.on('resize', () => {
updateLayout()
updateLayout(ref, layout.name, true)
})
updateLayout()
},
[cytoscapeRef],
[layout.name],
)

useEffect(() => {
const cy = cytoscapeRef.current
setElements([])
if (error || isLoading || !networkId || !heightSize) return
if (error || isLoading || !networkId || !heightSize || !cy) return

setElements(getNodes(txSettlement, networkId, heightSize))
setElements(getNodes(txSettlement, networkId, heightSize, layout.name))
if (resetZoom) {
updateLayout(cy, layout.name)
}
setResetZoom(null)
}, [error, isLoading, txSettlement, networkId, heightSize, resetZoom])
}, [error, isLoading, txSettlement, networkId, heightSize, resetZoom, layout.name])

useEffect(() => {
const cy = cytoscapeRef.current
Expand Down Expand Up @@ -288,7 +315,7 @@ function TransanctionBatchGraph({
<>
<WrapperCytoscape
elements={elements}
layout={getLayout()}
layout={layout}
style={{ width: '100%', height: heightSize }}
stylesheet={STYLESHEET(theme)}
cy={setCytoscape}
Expand All @@ -298,9 +325,28 @@ function TransanctionBatchGraph({
minZoom={0.1}
zoom={1}
/>
<ResetButton type="button" onClick={(): void => setResetZoom(!resetZoom)}>
<FontAwesomeIcon icon={faRedo} /> <span>Reset</span>
</ResetButton>
<FloatingWrapper>
<ResetButton type="button" onClick={(): void => setResetZoom(!resetZoom)}>
<FontAwesomeIcon icon={faRedo} /> <span>{layout.name === 'fcose' ? 'Re-arrange' : 'Reset'}</span>
</ResetButton>
<LayoutButton>
<DropdownWrapper
currentItem={currentLayoutIndex}
dropdownButtonContent={
<DropdownButtonContent icon={iconDice[currentLayoutIndex]} layout={LayoutNames[layout.name]} />
}
dropdownButtonContentOpened={
<DropdownButtonContent icon={iconDice[currentLayoutIndex]} layout={LayoutNames[layout.name]} open />
}
items={Object.values(LayoutNames).map((layoutName) => (
<DropdownOption key={layoutName} onClick={(): void => setLayout(layouts[layoutName.toLowerCase()])}>
{layoutName}
</DropdownOption>
))}
dropdownPosition={DropdownPosition.center}
/>
</LayoutButton>
</FloatingWrapper>
</>
)
}
Expand Down
103 changes: 103 additions & 0 deletions src/apps/explorer/components/TransanctionBatchGraph/layouts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { LayoutOptions, NodeSingular } from 'cytoscape'

export type CytoscapeLayouts = 'grid' | 'klay' | 'fcose'

type CustomLayoutOptions = LayoutOptions & {
[key: string]: unknown
}

const defaultValues = {
padding: 10, // padding used on fit
animate: true,
fit: true, // whether to fit the viewport to the graph
}
export const layouts: Record<CytoscapeLayouts, CustomLayoutOptions> = {
grid: {
...defaultValues,
name: 'grid',
position: (node: NodeSingular): { row: number; col: number } => ({ row: node.data('row'), col: node.data('col') }),
avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space
avoidOverlapPadding: 10, // extra spacing around nodes when avoidOverlap: true
nodeDimensionsIncludeLabels: false,
condense: false,
},
klay: {
...defaultValues,
name: 'klay',
klay: {
addUnnecessaryBendpoints: true, // Adds bend points even if an edge does not change direction.
aspectRatio: 1.6, // The aimed aspect ratio of the drawing, that is the quotient of width by height
borderSpacing: 20, // Minimal amount of space to be left to the border
compactComponents: false, // Tries to further compact components (disconnected sub-graphs).
edgeRouting: 'SPLINES',
edgeSpacingFactor: 2,
spacing: 20,
},
},
fcose: {
...defaultValues,
name: 'fcose',
quality: 'proof',
randomize: true,
animationDuration: 1000,
animationEasing: undefined,
nodeDimensionsIncludeLabels: false,
uniformNodeDimensions: false,
packComponents: true,
step: 'all',
/* spectral layout options */
// False for random, true for greedy sampling
samplingType: true,
// Sample size to construct distance matrix
sampleSize: 25,
// Separation amount between nodes
nodeSeparation: 75,
// Power iteration tolerance
piTol: 0.0000001,

/* incremental layout options */
// Node repulsion (non overlapping) multiplier
nodeRepulsion: (): number => 4500,
// Ideal edge (non nested) length
idealEdgeLength: (): number => 300,
// Divisor to compute edge forces
edgeElasticity: (): number => 0.01,
// Nesting factor (multiplier) to compute ideal edge length for nested edges
nestingFactor: 0.9,
// Maximum number of iterations to perform - this is a suggested value and might be adjusted by the algorithm as required
numIter: 2500,
// For enabling tiling
tile: true,
// Represents the amount of the vertical space to put between the zero degree members during the tiling operation(can also be a function)
tilingPaddingVertical: 10,
// Represents the amount of the horizontal space to put between the zero degree members during the tiling operation(can also be a function)
tilingPaddingHorizontal: 10,
// Gravity force (constant)
gravity: 0.8,
// Gravity range (constant) for compounds
gravityRangeCompound: 1.5,
// Gravity force (constant) for compounds
gravityCompound: 1.0,
// Gravity range (constant)
gravityRange: 0.5,
// Initial cooling factor for incremental layout
initialEnergyOnIncremental: 0.3,

/* constraint options */
// Fix desired nodes to predefined positions
// [{nodeId: 'n1', position: {x: 100, y: 200}}, {...}]
fixedNodeConstraint: undefined,
// Align desired nodes in vertical/horizontal direction
// {vertical: [['n1', 'n2'], [...]], horizontal: [['n2', 'n4'], [...]]}
alignmentConstraint: undefined,
// Place two nodes relatively in vertical/horizontal direction
// [{top: 'n1', bottom: 'n2', gap: 100}, {left: 'n3', right: 'n4', gap: 75}, {...}]
relativePlacementConstraint: undefined,
},
}

export enum LayoutNames {
grid = 'Grid',
klay = 'KLay',
fcose = 'FCoSE',
}
Loading