Skip to content

markjm610/bonsai

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

What is this?

A mini app that recursively renders a binary search tree and animates the nodes falling into their correct spots.

Technologies

  • TypeScript
  • React / Hooks
  • react-spring for animations
  • GraphQL / Apollo
  • Tests in Cypress and Enzyme/Jest
  • MongoDB / Mongoose

How it works

(Parts of the code have been removed to make the relevant parts shorter and easier to read)

Fetch tree data and store it in the state as a nested object for constant time lookup of nodes:

    useEffect(() => {
        if (rootData && treeNodesData) {

            // Object of TreeNodes
            const treeNodesObj: TreeObject = {}

            // This loop converts the tree from an array to a nested object for constant time lookup of nodes
            treeNodesData.treeNodes.forEach((treeNode: TreeNode) => {
                treeNodesObj[treeNode.id] = {
                    id: treeNode.id,
                    value: treeNode.value,
                    leftId: treeNode.leftId,
                    rightId: treeNode.rightId
                }
            })

            // Store the nested object in the state so this and other components have access to it
            setTreeState(treeNodesObj)
        }

    }, [rootData, treeNodesData])

The Leaf component is each node in the tree. It returns itself, rendering the tree recursively:

const Leaf: React.FC<Props> = ({...}) => {
// Component body and part of return statement not shown

// Component returns itself checking for left and right children
// Positions are relative to their parent node, and are adjusted by the level (nodes further down the tree are closer together)
return (
            <animated.div>
                { node &&
                    <>
                        {node.value}
                        {node.leftId &&
                            <Leaf
                                id={node.leftId}
                                position={{ x: -25 + level * 10, y: 10 }}
                                node={tree[node.leftId]}
                                tree={tree}
                                level={level + 1}
                                beginInsert={beginInsert}
                                setBeginInsert={setBeginInsert}
                                numberOfNodes={numberOfNodes}
                            />}
                        {node.rightId &&
                            <Leaf
                                id={node.rightId}
                                position={{ x: 25 - level * 10, y: 10 }}
                                node={tree[node.rightId]}
                                tree={tree}
                                level={level + 1}
                                beginInsert={beginInsert}
                                setBeginInsert={setBeginInsert}
                                numberOfNodes={numberOfNodes}
                            />}
                        }
                    </>
                }
            </animated.div >)

When a user adds a new node, the insertNode function is called to find where the node should be added and the path taken:

function insertNode(value: number, node: TreeNode, tree: TreeObject, level: number, traversedNodes: string[] = []): [string, boolean, string[]] {

    // The IDs of the traversed nodes will be used to determine the path of the animation
    // These IDs will later be compared to IDs of HTML elements on the screen to get locations
    traversedNodes.push(node.id)
    
    // The binary tree has a maximum size
    if (level > 3) {
        return ['invalid move', false, traversedNodes]
    }
    
    // Returns the ID of the node to add to, whether it is a left or right child, and the array of traversed node IDs
    if (value > node.value) {
        if (node.rightId) {
            return insertNode(value, tree[node.rightId], tree, level + 1, traversedNodes)
        } else {
            return [node.id, false, traversedNodes]
        }
    } else {
        if (node.leftId) {
            return insertNode(value, tree[node.leftId], tree, level + 1, traversedNodes)
        } else {
            return [node.id, true, traversedNodes]
        }
    }

When a new node is added, a FakeNode component is created as the input field is hidden. The fake node animates to the new node's position, creating the illusion that the new node is working its way down the tree:

// useSpring is from the react-spring library
const newNodeStyle = useSpring({

        from: { top: inputElementTop, left: inputElementLeft, position: 'fixed', backgroundColor: 'rgb(245, 245, 245)', opacity: 1 },
        to: async (next: Function) => {

            if (!animationDone && traversedNodeIds.length) {


                // For each node on the path, an animation waypoint is created using the IDs of each node found with the insertNode function
                let i = 0
                while (i < traversedNodeIds.length) {

                    // This step is how the fake node knows where to move to. 
                    // The IDs of the traversed nodes are also id fields on the corresponding HTML elements
                    const nodeRef = document.getElementById(traversedNodeIds[i])
                    const nodeLocation = nodeRef?.getBoundingClientRect()
                    if (!nodeLocation) {
                        return
                    }
                    if (!inputElementLeft) {
                        return
                    }
                    if (!inputElementTop) {
                        return
                    }
                  
                  
                    // The next function takes in the waypoints of the animation, found based on the locations of the traversed nodes
                    if (i === 0) {
                        await next({
                            top: nodeLocation.top,
                            left: nodeLocation.left,
                            backgroundColor: 'rgb(245, 245, 245)'
                        })
                    } else if (i === 1) {
                        await next({
                            top: nodeLocation.top,
                            left: nodeLocation.left,
                            backgroundColor: 'yellow'
                        })
                    } else if (i === 2) {
                        await next({
                            top: nodeLocation.top,
                            left: nodeLocation.left,
                            backgroundColor: 'orange'
                        })
                    } else {
                        await next({
                            top: nodeLocation.top,
                            left: nodeLocation.left,
                            backgroundColor: 'rgb(255, 69, 0)'
                        })
                    }

                    i++

                }

                // When the animation is done, a boolean value is flipped to true and the traversed node IDs are cleared
                setAnimationDone(true)
                setTraversedNodeIds([])

            }

        }

Now that the animation is complete, a mutation can be sent to the GraphQL server adding the new node, triggering a refetch of the tree data:

 useEffect(() => {
        if (animationDone) {
            addTreeNode({
                variables: {
                    value: parseInt(value),
                    root: false,
                    parentId: storedParentId,
                    isLeftChild: isStoredLeftChild
                },
                refetchQueries: [{ query: GET_TREENODES }]
            })

        }

    }, [animationDone])

The FakeNode component only renders conditionally when beginInsert is true (props not shown):

      {beginInsert && <FakeNode/>}

So, this means that by setting beginInsert to false, the fake node goes away, but this can only happen once the new node has taken its place to avoid flickering.

Refetching the tree data causes a new Leaf component to render. This triggers a useEffect on the new Leaf component that only runs when beginInsert is true:

    // If beginInsert is true, it means this Leaf was added by the user, not when the page first loaded.
    // By setting beginInsert to false, it removes the fake node. 
    useEffect(() => {
      if (beginInsert) {
          setBeginInsert(false)
      }
  }, [])

Now, the blank node/input form shows up again, the fake node is removed, and the new node is rendered in its correct spot.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published