├── .gitignore ├── README.md ├── assets ├── bundle.min.js ├── bundle.min.js.map └── main.css ├── demo.gif ├── index.html ├── package-lock.json ├── package.json ├── src ├── bst.js ├── html.js ├── main.js ├── render.js └── utils.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Binary Search Tree Visualizer 2 | 3 | ## Features 4 | - Animation speed control. 5 | - Inserting random numbers. 6 | - Ability to export trees as png images. 7 | - Showing value insertion steps. 8 | 9 | ## How to run locally 10 | 11 | - Clone this repository by running `git clone https://github.com/WinterCore/bst-visualizer.git` 12 | - Run `cd bst-visualizer` 13 | - Run `npm install` 14 | - Run `npm run dev` 15 | 16 | ## [Live Demo](https://wintercore.github.io/bst-visualizer) 17 | 18 | ![](./demo.gif) -------------------------------------------------------------------------------- /assets/bundle.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var n in i)("object"==typeof exports?exports:e)[n]=i[n]}}(window,(function(){return function(e){var t={};function i(n){if(t[n])return t[n].exports;var r=t[n]={i:n,l:!1,exports:{}};return e[n].call(r.exports,r,r.exports,i),r.l=!0,r.exports}return i.m=e,i.c=t,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var r in e)i.d(n,r,function(t){return e[t]}.bind(null,r));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="",i(i.s=0)}([function(e,t,i){"use strict";i.r(t);const n=e=>new Promise(t=>setTimeout(t,e)),r=(e,t,i,n)=>Math.sqrt(Math.pow(e-t,2)+Math.pow(i-n,2)),o=(e,t,i,n)=>Math.atan2(i-n,e-t);class s{constructor(e,t=null,i=null,n=null){this.value=e,this.left=i,this.right=n,this.parent=t,this.height=0,this.level=0,this.extras={initialized:!1,entered:!1}}}class a{static*push(e,t,i=null,n=0){return e?(t>e.value?(yield{highlightedNode:e,info:`Inserting ${t} | ${t} is bigger than ${e.value} so we go right!`},e.right=yield*a.push(e.right,t,e,n+1)):t{0===t&&0===i||a.breadthFirstTraverse(e,e=>{e.extras.dx+=t,e.extras.dy+=i,e.extras.transitioned=!1,e.extras.moveMultiplier=0})},d=(e,t)=>{let i=t.parent;const{padding:n,radius:r}=e;for(;i;){if(t.value>i.value&&t.extras.dx<=i.extras.dx){l(i.right,i.extras.dx-t.extras.dx+n+2*r),a.breadthFirstTraverse(i.right,t=>{d(e,t)});break}if(t.value=i.extras.dx){l(i.left,-(t.extras.dx-i.extras.dx+n+2*r)),a.breadthFirstTraverse(i.left,t=>{d(e,t)});break}i=i.parent}},u=(e,t,i=null)=>{if(t){if(t.extras.initialized){if(t.extras.entered||(t.extras.multiplier+=.06,t.extras.multiplier>1&&(t.extras.entered=!0)),!t.extras.transitioned){const{sx:e,sy:i,dx:n,dy:s,moveMultiplier:a}=t.extras;if(a>=1)return t.extras.transitioned=!0,t.extras.sx=n,t.extras.sy=s,t.extras.x=n,t.extras.y=s,void(t.moveMultiplier=0);const l=r(e,n,i,s)*a,d=o(n,e,s,i);t.extras.x=t.extras.sx+l*Math.cos(d),t.extras.y=t.extras.sy+l*Math.sin(d),t.extras.moveMultiplier+=.06}}else{t.extras.multiplier=0;const{x:n,y:r}=((e,t,i)=>{const{radius:n,padding:r,dimensions:o}=e;return i?{x:i.extras.dx+(i.left===t?-1:1)*(r+2*n),y:i.extras.dy+r+2*n}:{x:0,y:-o.height/2+r+n}})(e,t,i);t.extras.sx=n,t.extras.sy=r,t.extras.x=n,t.extras.y=r,t.extras.dx=n,t.extras.dy=r,t.extras.transitioned=!0,t.extras.moveMultiplier=0,d(e,t),(e=>{const{camera:t,tree:i}=e;a.breadthFirstTraverse(i,e=>{t.bounds.minX=Math.min(e.extras.x,t.bounds.minX),t.bounds.minY=Math.min(e.extras.y,t.bounds.minY),t.bounds.maxX=Math.max(e.extras.x,t.bounds.maxX),t.bounds.maxY=Math.max(e.extras.y,t.bounds.maxY)})})(e),t.extras.initialized=!0}u(e,t.left,t),u(e,t.right,t)}},h=(e,t,i=null)=>{if(!t)return;const{lineWidth:n,radius:o,fontSize:s,highlightedNode:a,ctx:l,colors:d}=e;if(l.save(),l.lineWidth=n,l.font=s+"pt Arial",i){l.save(),l.strokeStyle=d.connector;const e=i.extras.x-t.extras.x,n=i.extras.y-t.extras.y,s=Math.atan2(n,e),a={x:i.extras.x-o*Math.cos(s),y:i.extras.y-o*Math.sin(s)},u=r(i.extras.x,t.extras.x,i.extras.y,t.extras.y)-2*o,h={x:a.x+u*t.extras.multiplier*Math.cos(s+Math.PI),y:a.y+u*t.extras.multiplier*Math.sin(s+Math.PI)};l.beginPath(),l.moveTo(a.x,a.y),l.lineTo(h.x,h.y),l.stroke(),l.restore()}let{x:u,y:c}=t.extras;if(l.fillStyle=d.nodeBackground,l.strokeStyle=t===a?d.nodeBorderHighlight:d.nodeBorder,l.beginPath(),l.arc(u,c,t.extras.multiplier*o,0,2*Math.PI),l.stroke(),l.fill(),l.fillStyle=t===a?d.nodeTextHighlight:d.nodeText,t.extras.entered){const e=l.measureText(t.value);l.fillText(t.value,u-e.width/2,c+l.measureText("M").width/2-2)}l.restore(),h(e,t.left,t),h(e,t.right,t)};const c=document.querySelector("#canvas"),x={canvas:c,ctx:c.getContext("2d"),headsup:{curPos:{},nextPos:{},transitioned:!0,initialized:!1,multiplier:0},delay:2e3,highlightedNode:null,info:"",dimensions:{width:window.innerWidth,height:window.innerHeight},tree:null,mouse:{downPos:{x:0,y:0},oldCamera:{x:0,y:0},isDown:!1},camera:{x:0,y:0,zoom:1,bounds:{minX:1/0,maxX:-1/0,minY:1/0,maxY:-1/0}},colors:{nodeBorder:"black",nodeText:"black",nodeBorderHighlight:"red",nodeTextHighlight:"red",nodeBackground:"white",connector:"black",headsupBackground:"white",headsupColor:"red"},radius:30,padding:20,fontSize:14,lineWidth:5,inserting:!1},m=()=>{const{dimensions:{width:e,height:t},camera:i,highlightedNode:n,headsup:s,ctx:a}=x,l=e/2,d=t/2;a.save(),a.translate(l+i.x,d+i.y),a.scale(i.zoom,i.zoom),a.clearRect(100*-l,100*-d,200*e,200*t),u(x,x.tree),h(x,x.tree),n?((e=>{const{highlightedNode:t,headsup:i}=e,{x:n,y:r}=t.extras;if(!i.initialized)return i.curPos={x:n,y:r},i.nextPos={x:n,y:r},i.initialized=!0,void(i.transitioned=!0);i.transitioned?(i.nextPos={x:n,y:r},i.transitioned=!1,i.multiplier=0):(i.multiplier>=1&&(i.curPos={...i.nextPos},i.transitioned=!0),i.multiplier=Math.min(i.multiplier+.1,1))})(x),(e=>{const{radius:t,headsup:i,fontSize:n,ctx:s,info:a,lineWidth:l,colors:d}=e,{nextPos:{x:u,y:h},curPos:{x:c,y:x}}=i,m=r(u,c,h,x)*i.multiplier,y=o(u,c,h,x),g=m*Math.cos(y),f=m*Math.sin(y);s.font=.8*n+"pt Arial",s.save();const{width:p}=s.measureText(a),{width:v}=s.measureText("M");s.fillStyle=d.headsupBackground,s.fillRect(c+g+t+10,x+f-v/2-10,p+20,v+10),s.fillStyle=d.headsupColor,s.fillText(a,c+g+t+20,x+f),s.strokeStyle=d.headsupColor,s.lineWidth=l;const w=c+g,b=x+f-t-20;s.beginPath(),s.moveTo(w,b),s.lineTo(w,b-50),s.stroke(),s.beginPath(),s.moveTo(w,b+10),s.lineTo(w-10,b-5),s.lineTo(w+10,b-5),s.fill(),s.restore()})(x)):s.initialized=!1,a.restore(),window.requestAnimationFrame(m)};!function(e){const t=document.querySelector("#insert"),i=document.querySelector("#delay"),r=document.querySelector("#random"),o=document.querySelector("#help"),s=document.querySelector("#download-link");i.value=e.delay;const l=()=>{const{dimensions:t,canvas:i}=e;t.width=window.innerWidth,t.height=window.innerHeight,i.width=t.width,i.height=t.height};async function d(e,t){const i=a.push(e.tree,t);let r=i.next();for(;!r.done;){const{highlightedNode:t,info:o}=r.value;e.highlightedNode=t,e.info=o,e.highlightedNode&&await n(e.delay),r=i.next()}return r.value}window.addEventListener("resize",l),l(),canvas.addEventListener("mousedown",({clientX:t,clientY:i})=>{const{mouse:n,camera:r,canvas:o}=e;n.downPos.x=t,n.downPos.y=i,n.oldCamera.x=r.x,n.oldCamera.y=r.y,n.isDown=!0,o.style.cursor="grabbing"}),window.addEventListener("mouseup",()=>{const{mouse:t,canvas:i}=e;t.isDown=!1,i.style.cursor="grab"}),window.addEventListener("keydown",({key:t})=>{const{camera:i}=e;switch(t){case"ArrowUp":i.zoom+=.05;break;case"ArrowDown":i.zoom=Math.max(i.zoom-.05,0)}}),window.addEventListener("mousemove",({clientX:t,clientY:i})=>{const{mouse:n,tree:r,camera:o}=e;if(n.isDown&&r){const e=t-n.downPos.x+n.oldCamera.x,r=i-n.downPos.y+n.oldCamera.y;o.x=e,o.y=r}}),t.parentNode.querySelector("button").addEventListener("click",()=>{const{value:i}=t;e.inserting||i.length&&!Number.isNaN(+i)&&(e.inserting=!0,d(e,+i).then(t=>{e.tree=t,e.inserting=!1}).catch(console.log),t.value="")}),i.parentNode.querySelector("button").addEventListener("click",()=>{const{value:t}=i;t.length&&!Number.isNaN(+t)&&(e.delay=+t)}),r.addEventListener("click",async()=>{e.inserting||(e.inserting=!0,d(e,Math.floor(1e3*Math.random())).then(t=>{e.tree=t,e.inserting=!1}).catch(console.log))}),document.querySelector("#close-help").addEventListener("click",()=>{o.style.display="none"}),document.querySelector("#open-help").addEventListener("click",()=>{o.style.display="block"}),document.querySelector("#download").addEventListener("click",()=>{if(!e.tree)return;const{camera:t,radius:i,padding:n,dimensions:r,canvas:o}=e,a=t.zoom,l={x:t.x,y:t.y},d=2*(i+n);t.zoom=1,t.x=-t.bounds.minX-r.width/2+d/2,t.y=-t.bounds.minY-r.height/2+d/2,o.width=t.bounds.maxX-t.bounds.minX+d,o.height=t.bounds.maxY-t.bounds.minY+d,setTimeout(()=>{const e=o.toDataURL("image/png"),i=`bst_${Date.now()}.png`;s.href=e,s.download=i,s.click(),t.zoom=a,o.width=r.width,o.height=r.height,t.x=l.x,t.y=l.y},100)})}(x),window.requestAnimationFrame(m)}])})); 2 | //# sourceMappingURL=bundle.min.js.map -------------------------------------------------------------------------------- /assets/bundle.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"bundle.min.js","sources":["webpack:///bundle.min.js"],"mappings":"AAAA","sourceRoot":""} -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --input-padding: 10px; 3 | --primary-color: #0F191A; 4 | --washed-out: rgba(101, 101, 101, 0.5); 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | overflow: hidden; 14 | font-family: sans-serif, Arial; 15 | } 16 | 17 | canvas { 18 | cursor: grab; 19 | } 20 | 21 | button { 22 | background: var(--washed-out); 23 | border: none; 24 | color: white; 25 | cursor: pointer; 26 | padding: 4px 8px; 27 | transition: all 150ms ease-in-out; 28 | font-weight: bold; 29 | } 30 | 31 | button:hover { 32 | background: var(--primary-color); 33 | } 34 | 35 | input { 36 | border: 2px solid var(--washed-out); 37 | padding: 5px var(--input-padding); 38 | transition: all 150ms ease-in-out; 39 | } 40 | 41 | input:focus { 42 | border: 2px solid var(--primary-color); 43 | } 44 | 45 | .input-group { 46 | position: relative; 47 | } 48 | 49 | .input-group label { 50 | position: absolute; 51 | left: calc(var(--input-padding) / 2); 52 | top: -12px; 53 | pointer-events: none; 54 | font-size: 14px; 55 | transition: all 150ms ease-in-out; 56 | background: white; 57 | padding: 0 2px; 58 | } 59 | 60 | .input-group input:focus + label { 61 | color: var(--primary-color); 62 | } 63 | 64 | #info { 65 | position: fixed; 66 | top: 10px; 67 | left: 10px; 68 | color: red; 69 | } 70 | 71 | #insert, #speed { 72 | width: 100px; 73 | } 74 | 75 | #panel { 76 | display: flex; 77 | position: fixed; 78 | bottom: 5px; 79 | left: 5px; 80 | z-index: 1000; 81 | } 82 | 83 | #panel > * { 84 | margin: 0 10px; 85 | } 86 | 87 | #panel button { 88 | height: 100%; 89 | } 90 | 91 | #help { 92 | width: 100%; 93 | max-width: 400px; 94 | position: absolute; 95 | top: 10px; 96 | left: 10px; 97 | padding: 20px; 98 | background: white; 99 | box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); 100 | z-index: 1001; 101 | font-size: 14px; 102 | } 103 | #help h2 { 104 | margin: 0; 105 | } 106 | #help button { 107 | margin-top: 10px; 108 | } 109 | 110 | 111 | .github-corner:hover .octo-arm { 112 | animation: octocat-wave 560ms ease-in-out; 113 | } 114 | 115 | @keyframes octocat-wave { 116 | 0% { 117 | transform: rotate(0deg); 118 | } 119 | 120 | 20% { 121 | transform: rotate(-25deg); 122 | } 123 | 124 | 40% { 125 | transform: rotate(10deg); 126 | } 127 | 128 | 60% { 129 | transform: rotate(-25deg); 130 | } 131 | 132 | 80% { 133 | transform: rotate(10deg); 134 | } 135 | 136 | 100% { 137 | transform: rotate(0deg); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WinterCore/bst-visualizer/9466b05208437c93da0c7d87df702c652ea24a8e/demo.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Binary Tree Visualizer 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 | 31 |
32 |
33 | 34 | 48 | 49 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "binary-tree-visualizer", 3 | "version": "1.0.0", 4 | "description": "## Features - Animation speed control. - Inserting random numbers. - Ability to export trees as png images. - Showing value insertion steps.", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "webpack-dev-server", 8 | "build": "webpack --mode production" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/WinterCore/bst-visualizer.git" 13 | }, 14 | "keywords": [ 15 | "bst", 16 | "binary", 17 | "search", 18 | "tree", 19 | "visualizer", 20 | "visualizer", 21 | "stpes" 22 | ], 23 | "author": "WinterCore", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/WinterCore/bst-visualizer/issues" 27 | }, 28 | "homepage": "https://github.com/WinterCore/bst-visualizer#readme", 29 | "devDependencies": { 30 | "webpack": "^4.44.2", 31 | "webpack-cli": "^3.3.12", 32 | "webpack-dev-server": "^3.11.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/bst.js: -------------------------------------------------------------------------------- 1 | import { sleep } from "./utils"; 2 | 3 | class Node { 4 | constructor(value, parent = null, left = null, right = null) { 5 | this.value = value; 6 | this.left = left; 7 | this.right = right; 8 | this.parent = parent; 9 | this.height = 0; 10 | this.level = 0; 11 | 12 | this.extras = { 13 | initialized: false, 14 | entered: false 15 | }; 16 | } 17 | } 18 | 19 | 20 | export default class BST { 21 | static *push(node, value, parent = null, level = 0) { 22 | if (!node) { 23 | yield ({ info: "", highlightedNode: null }); 24 | return new Node(value, parent); 25 | } 26 | 27 | if (value > node.value) { 28 | yield({ 29 | highlightedNode: node, 30 | info: `Inserting ${value} | ${value} is bigger than ${node.value} so we go right!` 31 | }); 32 | 33 | node.right = yield *BST.push(node.right, value, node, level + 1); 34 | } else if (value < node.value) { 35 | yield({ 36 | highlightedNode: node, 37 | info: `Inserting ${value} | ${value} is less than ${node.value} so we go left!` 38 | }); 39 | 40 | node.left = yield *BST.push(node.left, value, node, level + 1); 41 | } else { 42 | yield({ 43 | highlightedNode: null, 44 | info: "" 45 | }); 46 | } 47 | 48 | node.height = Math.max(BST.height(node.left), BST.height(node.right)) + 1; 49 | node.level = level; 50 | 51 | return node; 52 | } 53 | 54 | static height(node) { 55 | if (!node) return 0; 56 | return node.height; 57 | } 58 | 59 | static breadthFirstTraverse(node, callback) { 60 | if (!node) return; 61 | const deque = []; 62 | 63 | deque.push(node); 64 | 65 | while (deque.length) { 66 | const current = deque.shift(); 67 | callback(current); 68 | if (current.left) deque.push(current.left); 69 | if (current.right) deque.push(current.right); 70 | } 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | import BST from "./bst"; 2 | import { sleep } from "./utils"; 3 | 4 | export default function initHtml(visualizer) { 5 | const $insert = document.querySelector("#insert"), 6 | $delay = document.querySelector("#delay"), 7 | $random = document.querySelector("#random"), 8 | $help = document.querySelector("#help"), 9 | $downloadLink = document.querySelector("#download-link"); 10 | 11 | $delay.value = visualizer.delay; 12 | 13 | 14 | const resize = () => { 15 | const { dimensions, canvas } = visualizer; 16 | dimensions.width = window.innerWidth; 17 | dimensions.height = window.innerHeight; 18 | 19 | canvas.width = dimensions.width; 20 | canvas.height = dimensions.height; 21 | }; 22 | 23 | 24 | window.addEventListener("resize", resize); 25 | resize(); 26 | 27 | canvas.addEventListener("mousedown", ({ clientX, clientY }) => { 28 | const { mouse, camera, canvas } = visualizer; 29 | 30 | mouse.downPos.x = clientX; 31 | mouse.downPos.y = clientY; 32 | 33 | mouse.oldCamera.x = camera.x; 34 | mouse.oldCamera.y = camera.y; 35 | 36 | mouse.isDown = true; 37 | canvas.style.cursor = "grabbing"; 38 | }); 39 | 40 | window.addEventListener("mouseup", () => { 41 | const { mouse, canvas } = visualizer; 42 | mouse.isDown = false; 43 | canvas.style.cursor = "grab"; 44 | }); 45 | 46 | window.addEventListener('keydown', ({ key }) => { 47 | const { camera } = visualizer; 48 | switch (key) { 49 | case "ArrowUp": // Up 50 | camera.zoom += 0.05; 51 | break; 52 | case "ArrowDown": // Down 53 | camera.zoom = Math.max(camera.zoom - 0.05, 0); 54 | break; 55 | } 56 | }); 57 | 58 | window.addEventListener("mousemove", ({ clientX, clientY }) => { 59 | const { mouse, tree, camera } = visualizer; 60 | 61 | if (mouse.isDown && tree) { 62 | const newX = (clientX - mouse.downPos.x) + mouse.oldCamera.x, 63 | newY = (clientY - mouse.downPos.y) + mouse.oldCamera.y; 64 | 65 | // I have no idea how this even works. it just does 66 | // const halfWidth = dimensions.width / 2, 67 | // halfHeight = dimensions.height / 2, 68 | // bxa = newX + halfWidth + camera.bounds.maxX, 69 | // bxb = newX - halfWidth + camera.bounds.minX, 70 | // bya = newY + halfHeight + camera.bounds.maxY, 71 | // byb = newY - halfHeight + camera.bounds.minY; 72 | 73 | // if (bxa < 0) camera.x = -halfWidth - camera.bounds.maxX; 74 | // else if (bxb > 0) camera.x = halfWidth - camera.bounds.minX; 75 | // else camera.x = newX; 76 | 77 | // if (bya < 0) camera.y = -halfHeight - camera.bounds.maxY; 78 | // else if (byb > 0) camera.y = halfHeight - camera.bounds.minY; 79 | // else camera.y = newY; 80 | 81 | // Disable camera bounds because it does not play well with the zooming functionality. 82 | camera.x = newX; 83 | camera.y = newY; 84 | } 85 | }); 86 | 87 | async function insertNode(visualizer, value) { 88 | const gen = BST.push(visualizer.tree, value); 89 | 90 | let data = gen.next(); 91 | while (!data.done) { 92 | const { highlightedNode, info } = data.value 93 | visualizer.highlightedNode = highlightedNode; 94 | visualizer.info = info; 95 | if (visualizer.highlightedNode) 96 | await sleep(visualizer.delay); 97 | data = gen.next(); 98 | } 99 | 100 | return data.value; 101 | } 102 | 103 | $insert.parentNode.querySelector("button").addEventListener("click", () => { 104 | const { value } = $insert; 105 | if (visualizer.inserting) return; 106 | if (!value.length || Number.isNaN(+value)) return; 107 | 108 | visualizer.inserting = true; 109 | insertNode(visualizer, +value) 110 | .then((tree) => { 111 | visualizer.tree = tree 112 | visualizer.inserting = false; 113 | }) 114 | .catch(console.log); 115 | $insert.value = ""; 116 | }); 117 | 118 | $delay.parentNode.querySelector("button").addEventListener("click", () => { 119 | const { value } = $delay; 120 | if (!value.length || Number.isNaN(+value)) return; 121 | 122 | 123 | 124 | visualizer.delay = +value; 125 | }); 126 | 127 | 128 | $random.addEventListener("click", async () => { 129 | if (visualizer.inserting) return; 130 | visualizer.inserting = true; 131 | 132 | insertNode(visualizer, Math.floor(Math.random() * 1000)) 133 | .then((tree) => { 134 | visualizer.tree = tree 135 | visualizer.inserting = false; 136 | }) 137 | .catch(console.log); 138 | 139 | }); 140 | 141 | document.querySelector("#close-help").addEventListener("click", () => { 142 | $help.style.display = "none"; 143 | }); 144 | 145 | document.querySelector("#open-help").addEventListener("click", () => { 146 | $help.style.display = "block"; 147 | }); 148 | 149 | document.querySelector("#download").addEventListener("click", () => { 150 | if (!visualizer.tree) return; 151 | const { camera, radius, padding, dimensions, canvas } = visualizer; 152 | 153 | const zoom = camera.zoom; 154 | const oldCam = { x: camera.x, y: camera.y }; 155 | const spacing = (radius + padding) * 2; 156 | camera.zoom = 1; 157 | camera.x = -camera.bounds.minX - dimensions.width / 2 + spacing / 2; 158 | camera.y = -camera.bounds.minY - dimensions.height / 2 + spacing / 2; 159 | canvas.width = camera.bounds.maxX - camera.bounds.minX + spacing; 160 | canvas.height = camera.bounds.maxY - camera.bounds.minY + spacing; 161 | setTimeout(() => { 162 | const data = canvas.toDataURL("image/png"), 163 | filename = `bst_${Date.now()}.png`; 164 | 165 | $downloadLink.href = data; 166 | $downloadLink.download = filename; 167 | $downloadLink.click(); 168 | camera.zoom = zoom; 169 | canvas.width = dimensions.width; 170 | canvas.height = dimensions.height; 171 | camera.x = oldCam.x; 172 | camera.y = oldCam.y; 173 | }, 100); 174 | }); 175 | 176 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { renderNode, renderHeadsup, updateHeadsup, updateNode } from "./render"; 2 | import initHtml from "./html"; 3 | 4 | const $canvas = document.querySelector("#canvas"); 5 | 6 | const visualizer = { 7 | canvas: $canvas, 8 | ctx: $canvas.getContext("2d"), 9 | 10 | headsup: { 11 | curPos: {}, 12 | nextPos: {}, 13 | transitioned: true, 14 | initialized: false, 15 | multiplier: 0, 16 | }, 17 | delay: 2000, 18 | highlightedNode: null, 19 | info: "", 20 | 21 | dimensions: { 22 | width: window.innerWidth, 23 | height: window.innerHeight 24 | }, 25 | 26 | tree: null, 27 | 28 | mouse: { 29 | downPos: { 30 | x: 0, 31 | y: 0 32 | }, 33 | oldCamera: { 34 | x: 0, 35 | y: 0 36 | }, 37 | isDown: false 38 | }, 39 | camera: { 40 | x: 0, 41 | y: 0, 42 | zoom: 1, 43 | bounds: { 44 | minX: Infinity, 45 | maxX: -Infinity, 46 | minY: Infinity, 47 | maxY: -Infinity 48 | } 49 | }, 50 | 51 | colors: { 52 | nodeBorder: "black", 53 | nodeText: "black", 54 | nodeBorderHighlight: "red", 55 | nodeTextHighlight: "red", 56 | nodeBackground: "white", 57 | connector: "black", 58 | 59 | headsupBackground: "white", 60 | headsupColor: "red" 61 | }, 62 | 63 | radius: 30, 64 | padding: 20, 65 | fontSize: 14, // in pts 66 | lineWidth: 5, 67 | 68 | inserting: false, 69 | }; 70 | 71 | const render = () => { 72 | const { 73 | dimensions: { width, height }, 74 | camera, 75 | highlightedNode, 76 | headsup, 77 | ctx 78 | } = visualizer; 79 | 80 | const hWidth = width / 2, 81 | hHeight = height / 2; 82 | 83 | ctx.save(); 84 | ctx.translate(hWidth + camera.x, hHeight + camera.y); 85 | ctx.scale(camera.zoom, camera.zoom); 86 | 87 | ctx.clearRect(-hWidth * 100, -hHeight * 100, width * 200, height * 200); 88 | 89 | updateNode(visualizer, visualizer.tree); 90 | renderNode(visualizer, visualizer.tree); 91 | 92 | if (highlightedNode) { 93 | updateHeadsup(visualizer); 94 | renderHeadsup(visualizer); 95 | } else { 96 | headsup.initialized = false; 97 | } 98 | 99 | ctx.restore(); 100 | window.requestAnimationFrame(render); 101 | }; 102 | 103 | initHtml(visualizer); 104 | window.requestAnimationFrame(render); 105 | 106 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | import BST from "./bst"; 2 | import * as utils from "./utils"; 3 | 4 | export const moveTree = (tree, dx = 0, dy = 0) => { 5 | if (dx === 0 && dy === 0) return; 6 | BST.breadthFirstTraverse(tree, (item) => { 7 | item.extras.dx += dx; 8 | item.extras.dy += dy; 9 | item.extras.transitioned = false; 10 | item.extras.moveMultiplier = 0; 11 | }); 12 | }; 13 | 14 | export const fixCollisions = (visualizer, node) => { 15 | let parent = node.parent; 16 | const { padding, radius } = visualizer; 17 | while (parent) { 18 | if (node.value > parent.value && node.extras.dx <= parent.extras.dx) { 19 | moveTree(parent.right, (parent.extras.dx - node.extras.dx + padding + radius * 2)); 20 | BST.breadthFirstTraverse(parent.right, (item) => { 21 | fixCollisions(visualizer, item); 22 | }); 23 | break; 24 | } else if (node.value < parent.value && node.extras.dx >= parent.extras.dx) { 25 | moveTree(parent.left, -(node.extras.dx - parent.extras.dx + padding + radius * 2)); 26 | BST.breadthFirstTraverse(parent.left, (item) => { 27 | fixCollisions(visualizer, item); 28 | }); 29 | break; 30 | } 31 | parent = parent.parent; 32 | } 33 | }; 34 | 35 | export const calculateNodePosition = (visualizer, node, parent) => { 36 | const { radius, padding, dimensions } = visualizer; 37 | return parent 38 | ? { 39 | x: parent.extras.dx + (parent.left === node ? -1 : +1) * (padding + radius * 2), 40 | y: parent.extras.dy + padding + radius * 2 41 | } : { 42 | x: 0, 43 | y: -(dimensions.height / 2) + padding + radius 44 | }; 45 | }; 46 | 47 | export const updateNode = (visualizer, node, parent = null) => { 48 | if (!node) return; 49 | 50 | if (!node.extras.initialized) { 51 | node.extras.multiplier = 0; 52 | const {x, y} = calculateNodePosition(visualizer, node, parent); 53 | 54 | node.extras.sx = x; 55 | node.extras.sy = y; 56 | node.extras.x = x; 57 | node.extras.y = y; 58 | node.extras.dx = x; 59 | node.extras.dy = y; 60 | node.extras.transitioned = true; 61 | node.extras.moveMultiplier = 0; 62 | // TODO: Find a better place to call these 2 functions 63 | fixCollisions(visualizer, node); 64 | utils.updateCameraBounds(visualizer); 65 | node.extras.initialized = true; 66 | } else { 67 | if (!node.extras.entered) { 68 | node.extras.multiplier += 0.06; 69 | if (node.extras.multiplier > 1) node.extras.entered = true; 70 | } 71 | 72 | if (!node.extras.transitioned) { 73 | const { sx, sy, dx, dy, moveMultiplier } = node.extras; 74 | if (moveMultiplier >= 1) { 75 | node.extras.transitioned = true; 76 | node.extras.sx = dx; 77 | node.extras.sy = dy; 78 | node.extras.x = dx; 79 | node.extras.y = dy; 80 | node.moveMultiplier = 0; 81 | return; 82 | } 83 | const distance = utils.distance(sx, dx, sy, dy) * moveMultiplier; 84 | const angle = utils.angle(dx, sx, dy, sy); 85 | 86 | node.extras.x = node.extras.sx + distance * Math.cos(angle); 87 | node.extras.y = node.extras.sy + distance * Math.sin(angle); 88 | 89 | node.extras.moveMultiplier += 0.06; 90 | } 91 | } 92 | 93 | updateNode(visualizer, node.left, node); 94 | updateNode(visualizer, node.right, node); 95 | }; 96 | 97 | export const renderNode = (visualizer, node, parent = null) => { 98 | if (!node) return; 99 | 100 | const { lineWidth, radius, fontSize, highlightedNode, ctx, colors } = visualizer; 101 | 102 | ctx.save(); 103 | 104 | ctx.lineWidth = lineWidth; 105 | ctx.font = `${fontSize}pt Arial`; 106 | 107 | 108 | // Draw connection 109 | if (parent) { 110 | ctx.save(); 111 | 112 | ctx.strokeStyle = colors.connector; 113 | 114 | const dx = (parent.extras.x - node.extras.x), 115 | dy = (parent.extras.y - node.extras.y); 116 | 117 | const angle = Math.atan2(dy, dx); 118 | const start = { 119 | x: parent.extras.x - radius * Math.cos(angle), 120 | y: parent.extras.y - radius * Math.sin(angle) 121 | }; 122 | 123 | const d = utils.distance(parent.extras.x, node.extras.x, parent.extras.y, node.extras.y) - radius * 2; 124 | 125 | const end = { 126 | x: start.x + (d * node.extras.multiplier * Math.cos(angle + Math.PI)), 127 | y: start.y + (d * node.extras.multiplier * Math.sin(angle + Math.PI)) 128 | }; 129 | 130 | 131 | ctx.beginPath(); 132 | ctx.moveTo(start.x, start.y); 133 | ctx.lineTo(end.x, end.y); 134 | ctx.stroke(); 135 | 136 | ctx.restore(); 137 | } 138 | 139 | 140 | // Draw circle 141 | let { x, y } = node.extras; 142 | ctx.fillStyle = colors.nodeBackground; 143 | ctx.strokeStyle = node === highlightedNode ? colors.nodeBorderHighlight : colors.nodeBorder; 144 | ctx.beginPath(); 145 | ctx.arc(x, y, node.extras.multiplier * radius, 0, Math.PI * 2); 146 | ctx.stroke(); 147 | ctx.fill(); 148 | 149 | 150 | 151 | // Draw text 152 | 153 | ctx.fillStyle = node === highlightedNode ? colors.nodeTextHighlight : colors.nodeText; 154 | if (node.extras.entered) { 155 | const textMeasurements = ctx.measureText(node.value); 156 | ctx.fillText(node.value, x - textMeasurements.width / 2, y + ctx.measureText("M").width / 2 - 2); 157 | } 158 | 159 | 160 | ctx.restore(); 161 | 162 | renderNode(visualizer, node.left, node); 163 | renderNode(visualizer, node.right, node); 164 | }; 165 | 166 | 167 | 168 | export const renderHeadsup = (visualizer) => { 169 | const { radius, headsup, fontSize, ctx, info, lineWidth, colors } = visualizer; 170 | 171 | const { nextPos: { x: nx, y: ny }, curPos : { x: cx, y: cy } } = headsup; 172 | const distance = utils.distance(nx, cx, ny, cy) * headsup.multiplier; 173 | const angle = utils.angle(nx, cx, ny, cy); 174 | 175 | const x = distance * Math.cos(angle), y = distance * Math.sin(angle); 176 | 177 | ctx.font = `${fontSize * 0.8}pt Arial`; 178 | ctx.save(); 179 | 180 | const { width } = ctx.measureText(info); 181 | const { width: height } = ctx.measureText("M"); 182 | 183 | const spacing = 10, lineLength = 50; 184 | 185 | 186 | ctx.fillStyle = colors.headsupBackground; 187 | ctx.fillRect(cx + x + radius + spacing, cy + y - height / 2 - spacing, width + spacing * 2, height + spacing); 188 | 189 | ctx.fillStyle = colors.headsupColor; 190 | ctx.fillText(info, cx + x + radius + spacing * 2, cy + y); 191 | 192 | 193 | ctx.strokeStyle = colors.headsupColor; 194 | ctx.lineWidth = lineWidth; 195 | 196 | const ax = cx + x, ay = cy + y - radius - spacing * 2; 197 | 198 | ctx.beginPath(); 199 | ctx.moveTo(ax, ay); 200 | ctx.lineTo(ax, ay - lineLength); 201 | ctx.stroke(); 202 | 203 | ctx.beginPath(); 204 | ctx.moveTo(ax, ay + 10); 205 | ctx.lineTo(ax - 10, ay - 5); 206 | ctx.lineTo(ax + 10, ay - 5); 207 | ctx.fill(); 208 | 209 | ctx.restore(); 210 | }; 211 | 212 | export const updateHeadsup = (visualizer) => { 213 | const { highlightedNode, headsup } = visualizer; 214 | const { x: nx, y: ny } = highlightedNode.extras; 215 | 216 | if (!headsup.initialized) { 217 | headsup.curPos = { x: nx, y: ny }; 218 | headsup.nextPos = { x: nx, y: ny }; 219 | headsup.initialized = true; 220 | headsup.transitioned = true; 221 | return; 222 | } 223 | 224 | if (headsup.transitioned) { 225 | headsup.nextPos = { x: nx, y: ny }; 226 | headsup.transitioned = false; 227 | headsup.multiplier = 0; 228 | } else { 229 | if (headsup.multiplier >= 1) { 230 | headsup.curPos = {...headsup.nextPos}; 231 | headsup.transitioned = true; 232 | } 233 | headsup.multiplier = Math.min(headsup.multiplier + 0.1, 1); 234 | } 235 | }; 236 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import BST from "./bst"; 2 | 3 | export const sleep = ms => new Promise((r) => setTimeout(r, ms)); 4 | export const distance = (x1, x2, y1, y2) => Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)); 5 | export const angle = (x1, x2, y1, y2) => Math.atan2(y1 - y2, x1 - x2); 6 | 7 | 8 | export const updateCameraBounds = (visualizer) => { 9 | const { camera, tree } = visualizer; 10 | 11 | BST.breadthFirstTraverse(tree, (item) => { 12 | camera.bounds.minX = Math.min(item.extras.x, camera.bounds.minX); 13 | camera.bounds.minY = Math.min(item.extras.y, camera.bounds.minY); 14 | camera.bounds.maxX = Math.max(item.extras.x, camera.bounds.maxX); 15 | camera.bounds.maxY = Math.max(item.extras.y, camera.bounds.maxY); 16 | }); 17 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | entry : "./src/main.js", 5 | devtool : "cheap-module-source-map", 6 | output : { 7 | path : path.join(__dirname, "assets"), 8 | filename : "bundle.min.js", 9 | libraryTarget : "umd" 10 | }, 11 | devServer: { 12 | port: 8080, 13 | publicPath: "/assets", 14 | open: true 15 | } 16 | }; --------------------------------------------------------------------------------