Переглянути джерело

Progress with Demo (frontend) implementation

Joeri Exelmans 3 роки тому
батько
коміт
5709877acf

+ 10 - 7
dist/index.html

@@ -5,33 +5,36 @@
     <title>Onion VCS</title>
     <style>
       body {
-        margin: 0;
-        
          -webkit-user-select: none;
          -moz-user-select: none;
          -ms-user-select: none;
          user-select: none;
       }
 
+      /* SVG style: */
+
       .container {
-        background-color: #333;
+        background-color: #ddd;
       }
 
       .node {
-        stroke: #fff;
+        stroke: #000;
         stroke-width: 1.5px;
       }
 
       .link {
-        stroke: #999;
+        stroke: #000;
         stroke-opacity: 1;
       }
 
+      .arrowHead {
+        fill: #000;
+      }
+
       .label {
         font-size: 10px;
-        fill: #fff;
+        fill: #000;
       }
-
     </style>
   </head>
   <body>

+ 3 - 0
package.json

@@ -20,6 +20,8 @@
 		"@types/node": "^18.6.1",
 		"@types/react": "^18.0.17",
 		"@types/react-dom": "^18.0.6",
+		"buffer": "^6.0.3",
+		"crypto-browserify": "^3.12.0",
 		"d3": "^7.6.1",
 		"d3-drag": "^3.0.0",
 		"d3-force": "^3.0.0",
@@ -28,6 +30,7 @@
 		"lodash": "^4.17.21",
 		"react": "^18.2.0",
 		"react-dom": "^18.2.0",
+		"stream-browserify": "^3.0.0",
 		"ts-node": "^10.9.1",
 		"typescript": "^4.7.4"
 	},

+ 285 - 7
pnpm-lock.yaml

@@ -13,6 +13,8 @@ specifiers:
   '@types/node': ^18.6.1
   '@types/react': ^18.0.17
   '@types/react-dom': ^18.0.6
+  buffer: ^6.0.3
+  crypto-browserify: ^3.12.0
   d3: ^7.6.1
   d3-drag: ^3.0.0
   d3-force: ^3.0.0
@@ -23,6 +25,7 @@ specifiers:
   nyc: ^15.1.0
   react: ^18.2.0
   react-dom: ^18.2.0
+  stream-browserify: ^3.0.0
   ts-loader: ^9.3.1
   ts-node: ^10.9.1
   typescript: ^4.7.4
@@ -42,6 +45,8 @@ dependencies:
   '@types/node': 18.6.1
   '@types/react': 18.0.17
   '@types/react-dom': 18.0.6
+  buffer: 6.0.3
+  crypto-browserify: 3.12.0
   d3: 7.6.1
   d3-drag: 3.0.0
   d3-force: 3.0.0
@@ -50,6 +55,7 @@ dependencies:
   lodash: 4.17.21
   react: 18.2.0
   react-dom: 18.2.0_react@18.2.0
+  stream-browserify: 3.0.0
   ts-node: 10.9.1_f6w67sjx3imwytyzb2qhabnzqe
   typescript: 4.7.4
 
@@ -1308,6 +1314,15 @@ packages:
     resolution: {integrity: sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==}
     dev: true
 
+  /asn1.js/5.4.1:
+    resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
+    dependencies:
+      bn.js: 4.12.0
+      inherits: 2.0.4
+      minimalistic-assert: 1.0.1
+      safer-buffer: 2.1.2
+    dev: false
+
   /babel-plugin-macros/3.1.0:
     resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
     engines: {node: '>=10', npm: '>=6'}
@@ -1321,6 +1336,10 @@ packages:
     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
     dev: true
 
+  /base64-js/1.5.1:
+    resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+    dev: false
+
   /batch/0.6.1:
     resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==}
     dev: true
@@ -1330,6 +1349,14 @@ packages:
     engines: {node: '>=8'}
     dev: true
 
+  /bn.js/4.12.0:
+    resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==}
+    dev: false
+
+  /bn.js/5.2.1:
+    resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==}
+    dev: false
+
   /body-parser/1.20.0:
     resolution: {integrity: sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==}
     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -1379,10 +1406,63 @@ packages:
       fill-range: 7.0.1
     dev: true
 
+  /brorand/1.1.0:
+    resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==}
+    dev: false
+
   /browser-stdout/1.3.1:
     resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==}
     dev: true
 
+  /browserify-aes/1.2.0:
+    resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==}
+    dependencies:
+      buffer-xor: 1.0.3
+      cipher-base: 1.0.4
+      create-hash: 1.2.0
+      evp_bytestokey: 1.0.3
+      inherits: 2.0.4
+      safe-buffer: 5.2.1
+    dev: false
+
+  /browserify-cipher/1.0.1:
+    resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==}
+    dependencies:
+      browserify-aes: 1.2.0
+      browserify-des: 1.0.2
+      evp_bytestokey: 1.0.3
+    dev: false
+
+  /browserify-des/1.0.2:
+    resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==}
+    dependencies:
+      cipher-base: 1.0.4
+      des.js: 1.0.1
+      inherits: 2.0.4
+      safe-buffer: 5.2.1
+    dev: false
+
+  /browserify-rsa/4.1.0:
+    resolution: {integrity: sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==}
+    dependencies:
+      bn.js: 5.2.1
+      randombytes: 2.1.0
+    dev: false
+
+  /browserify-sign/4.2.1:
+    resolution: {integrity: sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==}
+    dependencies:
+      bn.js: 5.2.1
+      browserify-rsa: 4.1.0
+      create-hash: 1.2.0
+      create-hmac: 1.1.7
+      elliptic: 6.5.4
+      inherits: 2.0.4
+      parse-asn1: 5.1.6
+      readable-stream: 3.6.0
+      safe-buffer: 5.2.1
+    dev: false
+
   /browserslist/4.21.2:
     resolution: {integrity: sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA==}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -1398,6 +1478,17 @@ packages:
     resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
     dev: true
 
+  /buffer-xor/1.0.3:
+    resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==}
+    dev: false
+
+  /buffer/6.0.3:
+    resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+    dependencies:
+      base64-js: 1.5.1
+      ieee754: 1.2.1
+    dev: false
+
   /bytes/3.0.0:
     resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==}
     engines: {node: '>= 0.8'}
@@ -1480,6 +1571,13 @@ packages:
     engines: {node: '>=6.0'}
     dev: true
 
+  /cipher-base/1.0.4:
+    resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==}
+    dependencies:
+      inherits: 2.0.4
+      safe-buffer: 5.2.1
+    dev: false
+
   /clean-stack/2.2.0:
     resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
     engines: {node: '>=6'}
@@ -1622,6 +1720,34 @@ packages:
       yaml: 1.10.2
     dev: false
 
+  /create-ecdh/4.0.4:
+    resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==}
+    dependencies:
+      bn.js: 4.12.0
+      elliptic: 6.5.4
+    dev: false
+
+  /create-hash/1.2.0:
+    resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==}
+    dependencies:
+      cipher-base: 1.0.4
+      inherits: 2.0.4
+      md5.js: 1.3.5
+      ripemd160: 2.0.2
+      sha.js: 2.4.11
+    dev: false
+
+  /create-hmac/1.1.7:
+    resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==}
+    dependencies:
+      cipher-base: 1.0.4
+      create-hash: 1.2.0
+      inherits: 2.0.4
+      ripemd160: 2.0.2
+      safe-buffer: 5.2.1
+      sha.js: 2.4.11
+    dev: false
+
   /create-require/1.1.1:
     resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
     dev: false
@@ -1635,6 +1761,22 @@ packages:
       which: 2.0.2
     dev: true
 
+  /crypto-browserify/3.12.0:
+    resolution: {integrity: sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==}
+    dependencies:
+      browserify-cipher: 1.0.1
+      browserify-sign: 4.2.1
+      create-ecdh: 4.0.4
+      create-hash: 1.2.0
+      create-hmac: 1.1.7
+      diffie-hellman: 5.0.3
+      inherits: 2.0.4
+      pbkdf2: 3.1.2
+      public-encrypt: 4.0.3
+      randombytes: 2.1.0
+      randomfill: 1.0.4
+    dev: false
+
   /csstype/3.0.9:
     resolution: {integrity: sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==}
     dev: false
@@ -1972,6 +2114,13 @@ packages:
     engines: {node: '>= 0.8'}
     dev: true
 
+  /des.js/1.0.1:
+    resolution: {integrity: sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==}
+    dependencies:
+      inherits: 2.0.4
+      minimalistic-assert: 1.0.1
+    dev: false
+
   /destroy/1.2.0:
     resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -1991,6 +2140,14 @@ packages:
     engines: {node: '>=0.3.1'}
     dev: true
 
+  /diffie-hellman/5.0.3:
+    resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==}
+    dependencies:
+      bn.js: 4.12.0
+      miller-rabin: 4.0.1
+      randombytes: 2.1.0
+    dev: false
+
   /dns-equal/1.0.0:
     resolution: {integrity: sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==}
     dev: true
@@ -2010,6 +2167,18 @@ packages:
     resolution: {integrity: sha512-nPyI7oHc8T64oSqRXrAt99gNMpk0SAgPHw/o+hkNKyb5+bcdnFtZcSO9FUJES5cVkVZvo8u4qiZ1gQILl8UXsA==}
     dev: true
 
+  /elliptic/6.5.4:
+    resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==}
+    dependencies:
+      bn.js: 4.12.0
+      brorand: 1.1.0
+      hash.js: 1.1.7
+      hmac-drbg: 1.0.1
+      inherits: 2.0.4
+      minimalistic-assert: 1.0.1
+      minimalistic-crypto-utils: 1.0.1
+    dev: false
+
   /emoji-regex/8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
     dev: true
@@ -2109,6 +2278,13 @@ packages:
     engines: {node: '>=0.8.x'}
     dev: true
 
+  /evp_bytestokey/1.0.3:
+    resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
+    dependencies:
+      md5.js: 1.3.5
+      safe-buffer: 5.2.1
+    dev: false
+
   /execa/5.1.1:
     resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
     engines: {node: '>=10'}
@@ -2373,6 +2549,22 @@ packages:
     dependencies:
       function-bind: 1.1.1
 
+  /hash-base/3.1.0:
+    resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==}
+    engines: {node: '>=4'}
+    dependencies:
+      inherits: 2.0.4
+      readable-stream: 3.6.0
+      safe-buffer: 5.2.1
+    dev: false
+
+  /hash.js/1.1.7:
+    resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
+    dependencies:
+      inherits: 2.0.4
+      minimalistic-assert: 1.0.1
+    dev: false
+
   /hasha/5.2.2:
     resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
     engines: {node: '>=8'}
@@ -2386,6 +2578,14 @@ packages:
     hasBin: true
     dev: true
 
+  /hmac-drbg/1.0.1:
+    resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
+    dependencies:
+      hash.js: 1.1.7
+      minimalistic-assert: 1.0.1
+      minimalistic-crypto-utils: 1.0.1
+    dev: false
+
   /hoist-non-react-statics/3.3.2:
     resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
     dependencies:
@@ -2487,6 +2687,10 @@ packages:
       safer-buffer: 2.1.2
     dev: false
 
+  /ieee754/1.2.1:
+    resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+    dev: false
+
   /import-fresh/3.3.0:
     resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
     engines: {node: '>=6'}
@@ -2527,7 +2731,6 @@ packages:
 
   /inherits/2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
-    dev: true
 
   /internmap/2.0.3:
     resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
@@ -2832,6 +3035,14 @@ packages:
     resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
     dev: false
 
+  /md5.js/1.3.5:
+    resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
+    dependencies:
+      hash-base: 3.1.0
+      inherits: 2.0.4
+      safe-buffer: 5.2.1
+    dev: false
+
   /media-typer/0.3.0:
     resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
     engines: {node: '>= 0.6'}
@@ -2865,6 +3076,14 @@ packages:
       picomatch: 2.3.1
     dev: true
 
+  /miller-rabin/4.0.1:
+    resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
+    hasBin: true
+    dependencies:
+      bn.js: 4.12.0
+      brorand: 1.1.0
+    dev: false
+
   /mime-db/1.52.0:
     resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
     engines: {node: '>= 0.6'}
@@ -2890,7 +3109,10 @@ packages:
 
   /minimalistic-assert/1.0.1:
     resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
-    dev: true
+
+  /minimalistic-crypto-utils/1.0.1:
+    resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
+    dev: false
 
   /minimatch/3.1.2:
     resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -3140,6 +3362,16 @@ packages:
       callsites: 3.1.0
     dev: false
 
+  /parse-asn1/5.1.6:
+    resolution: {integrity: sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==}
+    dependencies:
+      asn1.js: 5.4.1
+      browserify-aes: 1.2.0
+      evp_bytestokey: 1.0.3
+      pbkdf2: 3.1.2
+      safe-buffer: 5.2.1
+    dev: false
+
   /parse-json/5.2.0:
     resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
     engines: {node: '>=8'}
@@ -3182,6 +3414,17 @@ packages:
     engines: {node: '>=8'}
     dev: false
 
+  /pbkdf2/3.1.2:
+    resolution: {integrity: sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==}
+    engines: {node: '>=0.12'}
+    dependencies:
+      create-hash: 1.2.0
+      create-hmac: 1.1.7
+      ripemd160: 2.0.2
+      safe-buffer: 5.2.1
+      sha.js: 2.4.11
+    dev: false
+
   /picocolors/1.0.0:
     resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
     dev: true
@@ -3217,6 +3460,17 @@ packages:
       ipaddr.js: 1.9.1
     dev: true
 
+  /public-encrypt/4.0.3:
+    resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==}
+    dependencies:
+      bn.js: 4.12.0
+      browserify-rsa: 4.1.0
+      create-hash: 1.2.0
+      parse-asn1: 5.1.6
+      randombytes: 2.1.0
+      safe-buffer: 5.2.1
+    dev: false
+
   /punycode/2.1.1:
     resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
     engines: {node: '>=6'}
@@ -3233,7 +3487,13 @@ packages:
     resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
     dependencies:
       safe-buffer: 5.2.1
-    dev: true
+
+  /randomfill/1.0.4:
+    resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==}
+    dependencies:
+      randombytes: 2.1.0
+      safe-buffer: 5.2.1
+    dev: false
 
   /range-parser/1.2.1:
     resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
@@ -3304,7 +3564,6 @@ packages:
       inherits: 2.0.4
       string_decoder: 1.3.0
       util-deprecate: 1.0.2
-    dev: true
 
   /readdirp/3.6.0:
     resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
@@ -3386,6 +3645,13 @@ packages:
       glob: 7.2.0
     dev: true
 
+  /ripemd160/2.0.2:
+    resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==}
+    dependencies:
+      hash-base: 3.1.0
+      inherits: 2.0.4
+    dev: false
+
   /robust-predicates/3.0.1:
     resolution: {integrity: sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==}
     dev: false
@@ -3399,7 +3665,6 @@ packages:
 
   /safe-buffer/5.2.1:
     resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
-    dev: true
 
   /safer-buffer/2.1.2:
     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -3519,6 +3784,14 @@ packages:
     resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
     dev: true
 
+  /sha.js/2.4.11:
+    resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==}
+    hasBin: true
+    dependencies:
+      inherits: 2.0.4
+      safe-buffer: 5.2.1
+    dev: false
+
   /shallow-clone/3.0.1:
     resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
     engines: {node: '>=8'}
@@ -3627,6 +3900,13 @@ packages:
     engines: {node: '>= 0.8'}
     dev: true
 
+  /stream-browserify/3.0.0:
+    resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
+    dependencies:
+      inherits: 2.0.4
+      readable-stream: 3.6.0
+    dev: false
+
   /string-width/4.2.3:
     resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
     engines: {node: '>=8'}
@@ -3646,7 +3926,6 @@ packages:
     resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
     dependencies:
       safe-buffer: 5.2.1
-    dev: true
 
   /strip-ansi/6.0.1:
     resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
@@ -3900,7 +4179,6 @@ packages:
 
   /util-deprecate/1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
-    dev: true
 
   /utils-merge/1.0.1:
     resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}

+ 95 - 396
src/frontend/app.tsx

@@ -1,353 +1,21 @@
 import * as React from "react";
 import * as _ from "lodash";
 
-import {Grid, AppShell, Navbar, Header, Text, Title, MantineProvider, Tooltip, Group} from "@mantine/core";
-import {IconInfoCircle} from "@tabler/icons";
+import {Grid, Text, Title, Group, Stack} from "@mantine/core";
+
 import {Graph} from "./graph"
-import {InteractiveGraph} from "./interactive_graph";
+import {EditableGraph} from "./editable_graph";
 
-const graph = {
-  "nodes": [
-    { "id": "Myriel", "group": 1 },
-    { "id": "Napoleon", "group": 1 },
-    { "id": "Mlle.Baptistine", "group": 1 },
-    { "id": "Mme.Magloire", "group": 1 },
-    { "id": "CountessdeLo", "group": 1 },
-    { "id": "Geborand", "group": 1 },
-    { "id": "Champtercier", "group": 1 },
-    { "id": "Cravatte", "group": 1 },
-    { "id": "Count", "group": 1 },
-    { "id": "OldMan", "group": 1 },
-    { "id": "Labarre", "group": 2 },
-    { "id": "Valjean", "group": 2 },
-    { "id": "Marguerite", "group": 3 },
-    { "id": "Mme.deR", "group": 2 },
-    { "id": "Isabeau", "group": 2 },
-    { "id": "Gervais", "group": 2 },
-    { "id": "Tholomyes", "group": 3 },
-    { "id": "Listolier", "group": 3 },
-    { "id": "Fameuil", "group": 3 },
-    { "id": "Blacheville", "group": 3 },
-    { "id": "Favourite", "group": 3 },
-    { "id": "Dahlia", "group": 3 },
-    { "id": "Zephine", "group": 3 },
-    { "id": "Fantine", "group": 3 },
-    { "id": "Mme.Thenardier", "group": 4 },
-    { "id": "Thenardier", "group": 4 },
-    { "id": "Cosette", "group": 5 },
-    { "id": "Javert", "group": 4 },
-    { "id": "Fauchelevent", "group": 0 },
-    { "id": "Bamatabois", "group": 2 },
-    { "id": "Perpetue", "group": 3 },
-    { "id": "Simplice", "group": 2 },
-    { "id": "Scaufflaire", "group": 2 },
-    { "id": "Woman1", "group": 2 },
-    { "id": "Judge", "group": 2 },
-    { "id": "Champmathieu", "group": 2 },
-    { "id": "Brevet", "group": 2 },
-    { "id": "Chenildieu", "group": 2 },
-    { "id": "Cochepaille", "group": 2 },
-    { "id": "Pontmercy", "group": 4 },
-    { "id": "Boulatruelle", "group": 6 },
-    { "id": "Eponine", "group": 4 },
-    { "id": "Anzelma", "group": 4 },
-    { "id": "Woman2", "group": 5 },
-    { "id": "MotherInnocent", "group": 0 },
-    { "id": "Gribier", "group": 0 },
-    { "id": "Jondrette", "group": 7 },
-    { "id": "Mme.Burgon", "group": 7 },
-    { "id": "Gavroche", "group": 8 },
-    { "id": "Gillenormand", "group": 5 },
-    { "id": "Magnon", "group": 5 },
-    { "id": "Mlle.Gillenormand", "group": 5 },
-    { "id": "Mme.Pontmercy", "group": 5 },
-    { "id": "Mlle.Vaubois", "group": 5 },
-    { "id": "Lt.Gillenormand", "group": 5 },
-    { "id": "Marius", "group": 8 },
-    { "id": "BaronessT", "group": 5 },
-    { "id": "Mabeuf", "group": 8 },
-    { "id": "Enjolras", "group": 8 },
-    { "id": "Combeferre", "group": 8 },
-    { "id": "Prouvaire", "group": 8 },
-    { "id": "Feuilly", "group": 8 },
-    { "id": "Courfeyrac", "group": 8 },
-    { "id": "Bahorel", "group": 8 },
-    { "id": "Bossuet", "group": 8 },
-    { "id": "Joly", "group": 8 },
-    { "id": "Grantaire", "group": 8 },
-    { "id": "MotherPlutarch", "group": 9 },
-    { "id": "Gueulemer", "group": 4 },
-    { "id": "Babet", "group": 4 },
-    { "id": "Claquesous", "group": 4 },
-    { "id": "Montparnasse", "group": 4 },
-    { "id": "Toussaint", "group": 5 },
-    { "id": "Child1", "group": 10 },
-    { "id": "Child2", "group": 10 },
-    { "id": "Brujon", "group": 4 },
-    { "id": "Mme.Hucheloup", "group": 8 }
-  ],
-  "links": [
-    { "source": "Napoleon", "target": "Myriel", "value": 1 },
-    { "source": "Mlle.Baptistine", "target": "Myriel", "value": 8 },
-    { "source": "Mme.Magloire", "target": "Myriel", "value": 10 },
-    { "source": "Mme.Magloire", "target": "Mlle.Baptistine", "value": 6 },
-    { "source": "CountessdeLo", "target": "Myriel", "value": 1 },
-    { "source": "Geborand", "target": "Myriel", "value": 1 },
-    { "source": "Champtercier", "target": "Myriel", "value": 1 },
-    { "source": "Cravatte", "target": "Myriel", "value": 1 },
-    { "source": "Count", "target": "Myriel", "value": 2 },
-    { "source": "OldMan", "target": "Myriel", "value": 1 },
-    { "source": "Valjean", "target": "Labarre", "value": 1 },
-    { "source": "Valjean", "target": "Mme.Magloire", "value": 3 },
-    { "source": "Valjean", "target": "Mlle.Baptistine", "value": 3 },
-    { "source": "Valjean", "target": "Myriel", "value": 5 },
-    { "source": "Marguerite", "target": "Valjean", "value": 1 },
-    { "source": "Mme.deR", "target": "Valjean", "value": 1 },
-    { "source": "Isabeau", "target": "Valjean", "value": 1 },
-    { "source": "Gervais", "target": "Valjean", "value": 1 },
-    { "source": "Listolier", "target": "Tholomyes", "value": 4 },
-    { "source": "Fameuil", "target": "Tholomyes", "value": 4 },
-    { "source": "Fameuil", "target": "Listolier", "value": 4 },
-    { "source": "Blacheville", "target": "Tholomyes", "value": 4 },
-    { "source": "Blacheville", "target": "Listolier", "value": 4 },
-    { "source": "Blacheville", "target": "Fameuil", "value": 4 },
-    { "source": "Favourite", "target": "Tholomyes", "value": 3 },
-    { "source": "Favourite", "target": "Listolier", "value": 3 },
-    { "source": "Favourite", "target": "Fameuil", "value": 3 },
-    { "source": "Favourite", "target": "Blacheville", "value": 4 },
-    { "source": "Dahlia", "target": "Tholomyes", "value": 3 },
-    { "source": "Dahlia", "target": "Listolier", "value": 3 },
-    { "source": "Dahlia", "target": "Fameuil", "value": 3 },
-    { "source": "Dahlia", "target": "Blacheville", "value": 3 },
-    { "source": "Dahlia", "target": "Favourite", "value": 5 },
-    { "source": "Zephine", "target": "Tholomyes", "value": 3 },
-    { "source": "Zephine", "target": "Listolier", "value": 3 },
-    { "source": "Zephine", "target": "Fameuil", "value": 3 },
-    { "source": "Zephine", "target": "Blacheville", "value": 3 },
-    { "source": "Zephine", "target": "Favourite", "value": 4 },
-    { "source": "Zephine", "target": "Dahlia", "value": 4 },
-    { "source": "Fantine", "target": "Tholomyes", "value": 3 },
-    { "source": "Fantine", "target": "Listolier", "value": 3 },
-    { "source": "Fantine", "target": "Fameuil", "value": 3 },
-    { "source": "Fantine", "target": "Blacheville", "value": 3 },
-    { "source": "Fantine", "target": "Favourite", "value": 4 },
-    { "source": "Fantine", "target": "Dahlia", "value": 4 },
-    { "source": "Fantine", "target": "Zephine", "value": 4 },
-    { "source": "Fantine", "target": "Marguerite", "value": 2 },
-    { "source": "Fantine", "target": "Valjean", "value": 9 },
-    { "source": "Mme.Thenardier", "target": "Fantine", "value": 2 },
-    { "source": "Mme.Thenardier", "target": "Valjean", "value": 7 },
-    { "source": "Thenardier", "target": "Mme.Thenardier", "value": 13 },
-    { "source": "Thenardier", "target": "Fantine", "value": 1 },
-    { "source": "Thenardier", "target": "Valjean", "value": 12 },
-    { "source": "Cosette", "target": "Mme.Thenardier", "value": 4 },
-    { "source": "Cosette", "target": "Valjean", "value": 31 },
-    { "source": "Cosette", "target": "Tholomyes", "value": 1 },
-    { "source": "Cosette", "target": "Thenardier", "value": 1 },
-    { "source": "Javert", "target": "Valjean", "value": 17 },
-    { "source": "Javert", "target": "Fantine", "value": 5 },
-    { "source": "Javert", "target": "Thenardier", "value": 5 },
-    { "source": "Javert", "target": "Mme.Thenardier", "value": 1 },
-    { "source": "Javert", "target": "Cosette", "value": 1 },
-    { "source": "Fauchelevent", "target": "Valjean", "value": 8 },
-    { "source": "Fauchelevent", "target": "Javert", "value": 1 },
-    { "source": "Bamatabois", "target": "Fantine", "value": 1 },
-    { "source": "Bamatabois", "target": "Javert", "value": 1 },
-    { "source": "Bamatabois", "target": "Valjean", "value": 2 },
-    { "source": "Perpetue", "target": "Fantine", "value": 1 },
-    { "source": "Simplice", "target": "Perpetue", "value": 2 },
-    { "source": "Simplice", "target": "Valjean", "value": 3 },
-    { "source": "Simplice", "target": "Fantine", "value": 2 },
-    { "source": "Simplice", "target": "Javert", "value": 1 },
-    { "source": "Scaufflaire", "target": "Valjean", "value": 1 },
-    { "source": "Woman1", "target": "Valjean", "value": 2 },
-    { "source": "Woman1", "target": "Javert", "value": 1 },
-    { "source": "Judge", "target": "Valjean", "value": 3 },
-    { "source": "Judge", "target": "Bamatabois", "value": 2 },
-    { "source": "Champmathieu", "target": "Valjean", "value": 3 },
-    { "source": "Champmathieu", "target": "Judge", "value": 3 },
-    { "source": "Champmathieu", "target": "Bamatabois", "value": 2 },
-    { "source": "Brevet", "target": "Judge", "value": 2 },
-    { "source": "Brevet", "target": "Champmathieu", "value": 2 },
-    { "source": "Brevet", "target": "Valjean", "value": 2 },
-    { "source": "Brevet", "target": "Bamatabois", "value": 1 },
-    { "source": "Chenildieu", "target": "Judge", "value": 2 },
-    { "source": "Chenildieu", "target": "Champmathieu", "value": 2 },
-    { "source": "Chenildieu", "target": "Brevet", "value": 2 },
-    { "source": "Chenildieu", "target": "Valjean", "value": 2 },
-    { "source": "Chenildieu", "target": "Bamatabois", "value": 1 },
-    { "source": "Cochepaille", "target": "Judge", "value": 2 },
-    { "source": "Cochepaille", "target": "Champmathieu", "value": 2 },
-    { "source": "Cochepaille", "target": "Brevet", "value": 2 },
-    { "source": "Cochepaille", "target": "Chenildieu", "value": 2 },
-    { "source": "Cochepaille", "target": "Valjean", "value": 2 },
-    { "source": "Cochepaille", "target": "Bamatabois", "value": 1 },
-    { "source": "Pontmercy", "target": "Thenardier", "value": 1 },
-    { "source": "Boulatruelle", "target": "Thenardier", "value": 1 },
-    { "source": "Eponine", "target": "Mme.Thenardier", "value": 2 },
-    { "source": "Eponine", "target": "Thenardier", "value": 3 },
-    { "source": "Anzelma", "target": "Eponine", "value": 2 },
-    { "source": "Anzelma", "target": "Thenardier", "value": 2 },
-    { "source": "Anzelma", "target": "Mme.Thenardier", "value": 1 },
-    { "source": "Woman2", "target": "Valjean", "value": 3 },
-    { "source": "Woman2", "target": "Cosette", "value": 1 },
-    { "source": "Woman2", "target": "Javert", "value": 1 },
-    { "source": "MotherInnocent", "target": "Fauchelevent", "value": 3 },
-    { "source": "MotherInnocent", "target": "Valjean", "value": 1 },
-    { "source": "Gribier", "target": "Fauchelevent", "value": 2 },
-    { "source": "Mme.Burgon", "target": "Jondrette", "value": 1 },
-    { "source": "Gavroche", "target": "Mme.Burgon", "value": 2 },
-    { "source": "Gavroche", "target": "Thenardier", "value": 1 },
-    { "source": "Gavroche", "target": "Javert", "value": 1 },
-    { "source": "Gavroche", "target": "Valjean", "value": 1 },
-    { "source": "Gillenormand", "target": "Cosette", "value": 3 },
-    { "source": "Gillenormand", "target": "Valjean", "value": 2 },
-    { "source": "Magnon", "target": "Gillenormand", "value": 1 },
-    { "source": "Magnon", "target": "Mme.Thenardier", "value": 1 },
-    { "source": "Mlle.Gillenormand", "target": "Gillenormand", "value": 9 },
-    { "source": "Mlle.Gillenormand", "target": "Cosette", "value": 2 },
-    { "source": "Mlle.Gillenormand", "target": "Valjean", "value": 2 },
-    { "source": "Mme.Pontmercy", "target": "Mlle.Gillenormand", "value": 1 },
-    { "source": "Mme.Pontmercy", "target": "Pontmercy", "value": 1 },
-    { "source": "Mlle.Vaubois", "target": "Mlle.Gillenormand", "value": 1 },
-    { "source": "Lt.Gillenormand", "target": "Mlle.Gillenormand", "value": 2 },
-    { "source": "Lt.Gillenormand", "target": "Gillenormand", "value": 1 },
-    { "source": "Lt.Gillenormand", "target": "Cosette", "value": 1 },
-    { "source": "Marius", "target": "Mlle.Gillenormand", "value": 6 },
-    { "source": "Marius", "target": "Gillenormand", "value": 12 },
-    { "source": "Marius", "target": "Pontmercy", "value": 1 },
-    { "source": "Marius", "target": "Lt.Gillenormand", "value": 1 },
-    { "source": "Marius", "target": "Cosette", "value": 21 },
-    { "source": "Marius", "target": "Valjean", "value": 19 },
-    { "source": "Marius", "target": "Tholomyes", "value": 1 },
-    { "source": "Marius", "target": "Thenardier", "value": 2 },
-    { "source": "Marius", "target": "Eponine", "value": 5 },
-    { "source": "Marius", "target": "Gavroche", "value": 4 },
-    { "source": "BaronessT", "target": "Gillenormand", "value": 1 },
-    { "source": "BaronessT", "target": "Marius", "value": 1 },
-    { "source": "Mabeuf", "target": "Marius", "value": 1 },
-    { "source": "Mabeuf", "target": "Eponine", "value": 1 },
-    { "source": "Mabeuf", "target": "Gavroche", "value": 1 },
-    { "source": "Enjolras", "target": "Marius", "value": 7 },
-    { "source": "Enjolras", "target": "Gavroche", "value": 7 },
-    { "source": "Enjolras", "target": "Javert", "value": 6 },
-    { "source": "Enjolras", "target": "Mabeuf", "value": 1 },
-    { "source": "Enjolras", "target": "Valjean", "value": 4 },
-    { "source": "Combeferre", "target": "Enjolras", "value": 15 },
-    { "source": "Combeferre", "target": "Marius", "value": 5 },
-    { "source": "Combeferre", "target": "Gavroche", "value": 6 },
-    { "source": "Combeferre", "target": "Mabeuf", "value": 2 },
-    { "source": "Prouvaire", "target": "Gavroche", "value": 1 },
-    { "source": "Prouvaire", "target": "Enjolras", "value": 4 },
-    { "source": "Prouvaire", "target": "Combeferre", "value": 2 },
-    { "source": "Feuilly", "target": "Gavroche", "value": 2 },
-    { "source": "Feuilly", "target": "Enjolras", "value": 6 },
-    { "source": "Feuilly", "target": "Prouvaire", "value": 2 },
-    { "source": "Feuilly", "target": "Combeferre", "value": 5 },
-    { "source": "Feuilly", "target": "Mabeuf", "value": 1 },
-    { "source": "Feuilly", "target": "Marius", "value": 1 },
-    { "source": "Courfeyrac", "target": "Marius", "value": 9 },
-    { "source": "Courfeyrac", "target": "Enjolras", "value": 17 },
-    { "source": "Courfeyrac", "target": "Combeferre", "value": 13 },
-    { "source": "Courfeyrac", "target": "Gavroche", "value": 7 },
-    { "source": "Courfeyrac", "target": "Mabeuf", "value": 2 },
-    { "source": "Courfeyrac", "target": "Eponine", "value": 1 },
-    { "source": "Courfeyrac", "target": "Feuilly", "value": 6 },
-    { "source": "Courfeyrac", "target": "Prouvaire", "value": 3 },
-    { "source": "Bahorel", "target": "Combeferre", "value": 5 },
-    { "source": "Bahorel", "target": "Gavroche", "value": 5 },
-    { "source": "Bahorel", "target": "Courfeyrac", "value": 6 },
-    { "source": "Bahorel", "target": "Mabeuf", "value": 2 },
-    { "source": "Bahorel", "target": "Enjolras", "value": 4 },
-    { "source": "Bahorel", "target": "Feuilly", "value": 3 },
-    { "source": "Bahorel", "target": "Prouvaire", "value": 2 },
-    { "source": "Bahorel", "target": "Marius", "value": 1 },
-    { "source": "Bossuet", "target": "Marius", "value": 5 },
-    { "source": "Bossuet", "target": "Courfeyrac", "value": 12 },
-    { "source": "Bossuet", "target": "Gavroche", "value": 5 },
-    { "source": "Bossuet", "target": "Bahorel", "value": 4 },
-    { "source": "Bossuet", "target": "Enjolras", "value": 10 },
-    { "source": "Bossuet", "target": "Feuilly", "value": 6 },
-    { "source": "Bossuet", "target": "Prouvaire", "value": 2 },
-    { "source": "Bossuet", "target": "Combeferre", "value": 9 },
-    { "source": "Bossuet", "target": "Mabeuf", "value": 1 },
-    { "source": "Bossuet", "target": "Valjean", "value": 1 },
-    { "source": "Joly", "target": "Bahorel", "value": 5 },
-    { "source": "Joly", "target": "Bossuet", "value": 7 },
-    { "source": "Joly", "target": "Gavroche", "value": 3 },
-    { "source": "Joly", "target": "Courfeyrac", "value": 5 },
-    { "source": "Joly", "target": "Enjolras", "value": 5 },
-    { "source": "Joly", "target": "Feuilly", "value": 5 },
-    { "source": "Joly", "target": "Prouvaire", "value": 2 },
-    { "source": "Joly", "target": "Combeferre", "value": 5 },
-    { "source": "Joly", "target": "Mabeuf", "value": 1 },
-    { "source": "Joly", "target": "Marius", "value": 2 },
-    { "source": "Grantaire", "target": "Bossuet", "value": 3 },
-    { "source": "Grantaire", "target": "Enjolras", "value": 3 },
-    { "source": "Grantaire", "target": "Combeferre", "value": 1 },
-    { "source": "Grantaire", "target": "Courfeyrac", "value": 2 },
-    { "source": "Grantaire", "target": "Joly", "value": 2 },
-    { "source": "Grantaire", "target": "Gavroche", "value": 1 },
-    { "source": "Grantaire", "target": "Bahorel", "value": 1 },
-    { "source": "Grantaire", "target": "Feuilly", "value": 1 },
-    { "source": "Grantaire", "target": "Prouvaire", "value": 1 },
-    { "source": "MotherPlutarch", "target": "Mabeuf", "value": 3 },
-    { "source": "Gueulemer", "target": "Thenardier", "value": 5 },
-    { "source": "Gueulemer", "target": "Valjean", "value": 1 },
-    { "source": "Gueulemer", "target": "Mme.Thenardier", "value": 1 },
-    { "source": "Gueulemer", "target": "Javert", "value": 1 },
-    { "source": "Gueulemer", "target": "Gavroche", "value": 1 },
-    { "source": "Gueulemer", "target": "Eponine", "value": 1 },
-    { "source": "Babet", "target": "Thenardier", "value": 6 },
-    { "source": "Babet", "target": "Gueulemer", "value": 6 },
-    { "source": "Babet", "target": "Valjean", "value": 1 },
-    { "source": "Babet", "target": "Mme.Thenardier", "value": 1 },
-    { "source": "Babet", "target": "Javert", "value": 2 },
-    { "source": "Babet", "target": "Gavroche", "value": 1 },
-    { "source": "Babet", "target": "Eponine", "value": 1 },
-    { "source": "Claquesous", "target": "Thenardier", "value": 4 },
-    { "source": "Claquesous", "target": "Babet", "value": 4 },
-    { "source": "Claquesous", "target": "Gueulemer", "value": 4 },
-    { "source": "Claquesous", "target": "Valjean", "value": 1 },
-    { "source": "Claquesous", "target": "Mme.Thenardier", "value": 1 },
-    { "source": "Claquesous", "target": "Javert", "value": 1 },
-    { "source": "Claquesous", "target": "Eponine", "value": 1 },
-    { "source": "Claquesous", "target": "Enjolras", "value": 1 },
-    { "source": "Montparnasse", "target": "Javert", "value": 1 },
-    { "source": "Montparnasse", "target": "Babet", "value": 2 },
-    { "source": "Montparnasse", "target": "Gueulemer", "value": 2 },
-    { "source": "Montparnasse", "target": "Claquesous", "value": 2 },
-    { "source": "Montparnasse", "target": "Valjean", "value": 1 },
-    { "source": "Montparnasse", "target": "Gavroche", "value": 1 },
-    { "source": "Montparnasse", "target": "Eponine", "value": 1 },
-    { "source": "Montparnasse", "target": "Thenardier", "value": 1 },
-    { "source": "Toussaint", "target": "Cosette", "value": 2 },
-    { "source": "Toussaint", "target": "Javert", "value": 1 },
-    { "source": "Toussaint", "target": "Valjean", "value": 1 },
-    { "source": "Child1", "target": "Gavroche", "value": 2 },
-    { "source": "Child2", "target": "Gavroche", "value": 2 },
-    { "source": "Child2", "target": "Child1", "value": 3 },
-    { "source": "Brujon", "target": "Babet", "value": 3 },
-    { "source": "Brujon", "target": "Gueulemer", "value": 3 },
-    { "source": "Brujon", "target": "Thenardier", "value": 3 },
-    { "source": "Brujon", "target": "Gavroche", "value": 1 },
-    { "source": "Brujon", "target": "Eponine", "value": 1 },
-    { "source": "Brujon", "target": "Claquesous", "value": 1 },
-    { "source": "Brujon", "target": "Montparnasse", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Bossuet", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Joly", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Grantaire", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Bahorel", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Courfeyrac", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Gavroche", "value": 1 },
-    { "source": "Mme.Hucheloup", "target": "Enjolras", "value": 1 }
-  ]
-};
+import {mockUuid} from "../onion/test_helpers";
+import {IVersion, initialVersion} from "../onion/version";
+import {Delta} from "../onion/delta";
+import {CompositeDelta} from "../onion/composite_delta";
+import {NodeCreation, NodeDeletion, EdgeCreation, EdgeUpdate} from "../onion/primitive_delta";
 
 const emptyGraph = {
   nodes: [],
   links: [],
-}
+};
 
 const graph2 = {
   nodes: [
@@ -360,64 +28,95 @@ const graph2 = {
     {source: "1", target: "2", value: 1},
     {source: "2", target: "0", value: 1},
   ],
+};
+
+const initialHistoryGraph = {
+  nodes: [
+    {id: shortVersionId(initialVersion), color:"purple", obj:initialVersion},
+  ],
+  links: [],
+};
+
+function shortVersionId(version: IVersion) {
+  return version.hash.toString('hex').slice(0,8);
+}
+
+function shortDeltaId(delta: Delta) {
+  return delta.getHash().toString('hex').slice(0,8);
 }
 
+export function App() {
+  const getUuid = mockUuid();
+
+  const refHistoryGraph = React.useRef<Graph>(null);
+  const refDependencyGraph = React.useRef<Graph>(null);
 
-export class App extends React.Component {
-  render() {
-    return (
-      <MantineProvider
-        withGlobalStyles
-        withNormalizeCSS
-        theme={{
-          colorScheme: 'dark',
-          colors: {
-            // override dark colors to change them for all components
-            dark: [
-              '#d5d7e0',
-              '#acaebf',
-              '#8c8fa3',
-              '#666980',
-              '#4d4f66',
-              '#34354a',
-              '#2b2c3d',
-              '#1d1e30',
-              '#0c0d21',
-              '#01010a',
-            ],
-          },
-        }}
-      >
-      <AppShell
-        padding="md"
-            // navbar={<Navbar width={{ base: 300 }} height={500} p="xs">{/* Navbar content */}</Navbar>}
-            header={<Header height={60} p="xs">{<Title>Onion VCS Demo</Title>}</Header>}
-            styles={(theme) => ({
-              main: { backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0] },
-            })}
-          >
-        <Grid grow columns={4}>
-          <Grid.Col span={2}>
-            <Group>
-              <Title order={4}>Graph state</Title>
-              <Tooltip label="Double click to create node">
-                <Text><IconInfoCircle/></Text>
-              </Tooltip>
-            </Group>
-            <InteractiveGraph graph={_.cloneDeep(graph2)} />
-          </Grid.Col>
-          <Grid.Col span={1}>
-            <Title order={4}>Version</Title>
-            <Graph graph={_.cloneDeep(emptyGraph)} mouseDownHandler={()=>{}} mouseUpHandler={()=>{}} />
-          </Grid.Col>
-          <Grid.Col span={1}>
-            <Title order={4}>Delta</Title>
-            <Graph graph={_.cloneDeep(emptyGraph)} mouseDownHandler={()=>{}} mouseUpHandler={()=>{}} />
-          </Grid.Col>
-        </Grid>
-      </AppShell>
-    </MantineProvider>
-    );
+  const newVersionHandler = (version: IVersion) => {
+    if (refHistoryGraph.current !== null) {
+      refHistoryGraph.current.createNode({id: shortVersionId(version), x:0, y:0, color:"purple", obj:version});
+      for (const parent of version.parents) {
+        const [parentVersion, delta] = parent;
+        refHistoryGraph.current.createLink({source:shortVersionId(version), label:"parent", target:shortVersionId(parentVersion), obj: delta});
+      }
+    }
+  };
+
+  const newDeltaHandler = (delta: Delta) => {
+    if (refDependencyGraph.current !== null) {
+      let numCreations = 0;
+      let numDeletions = 0;
+      let numEdgeUpdates = 0;
+      let numDeltas = 0;
+      function countDeltas(delta) {
+        if (delta instanceof CompositeDelta) {
+          delta.deltas.forEach(countDeltas);
+        }
+        else {
+          numDeltas++;
+          if (delta instanceof NodeCreation) {
+            numCreations++;
+          }
+          else if (delta instanceof NodeDeletion) {
+            numDeletions++;
+          }
+          else if (delta instanceof EdgeCreation || delta instanceof EdgeUpdate) {
+            numEdgeUpdates++;
+          }
+        }
+      }
+      countDeltas(delta);
+      const color = `rgb(${255*numDeletions/numDeltas}, ${255*numCreations/numDeltas}, ${255*numEdgeUpdates/numDeltas})`;
+      console.log(color)
+      refDependencyGraph.current.createNode({id: shortDeltaId(delta), x:0, y:0, color, obj:delta});
+      for (const dep of delta.getDependencies()) {
+        refDependencyGraph.current.createLink({source: shortDeltaId(delta), label: "dep", target:shortDeltaId(dep), obj: null});
+      }
+    }
   }
+
+  return (
+    <Stack>
+      <Title>Onion VCS Demo</Title>
+      <Grid grow>
+        <Grid.Col span={1}>
+          <Group>
+            <Title order={4}>Graph state</Title>
+          </Group>
+          <EditableGraph graph={_.cloneDeep(emptyGraph)} getUuid={getUuid} newVersionHandler={newVersionHandler} newDeltaHandler={newDeltaHandler} />
+          <Text>Left mouse button: Drag node around.</Text>
+          <Text>Middle mouse button: Delete node.</Text>
+          <Text>Right mouse button: Create node or edge.</Text>
+        </Grid.Col>
+        <Grid.Col span={1}>
+          <Title order={4}>History Graph</Title>
+          <Graph ref={refHistoryGraph} graph={_.cloneDeep(initialHistoryGraph)} mouseDownHandler={()=>{}} mouseUpHandler={()=>{}} />
+        </Grid.Col>
+        <Grid.Col span={1}>
+          <Title order={4}>Dependency Graph</Title>
+          <Graph ref={refDependencyGraph} graph={_.cloneDeep(emptyGraph)} mouseDownHandler={()=>{}} mouseUpHandler={()=>{}} />
+        </Grid.Col>
+      </Grid>
+    </Stack>
+  );
 }
  

+ 124 - 0
src/frontend/editable_graph.tsx

@@ -0,0 +1,124 @@
+import * as React from 'react';
+// import {Modal, Autocomplete} from "@mantine/core"
+import {d3Types, Graph, GraphProps} from "./graph";
+
+import {
+  NodeCreation,
+  NodeDeletion,
+  EdgeCreation,
+  EdgeUpdate,
+} from "../onion/primitive_delta";
+
+import {
+  // CompositeDelta,
+  CompositeLevel,
+} from "../onion/composite_delta";
+
+import {UUID} from "../onion/types";
+
+import {
+  IVersion,
+  initialVersion,
+  VersionRegistry,
+} from "../onion/version";
+
+interface EditableGraphProps {
+  graph: d3Types.d3Graph;
+  getUuid: () => UUID;
+  newVersionHandler: (IVersion) => void;
+  newDeltaHandler: (Delta) => void;
+}
+
+interface EditableGraphState {
+}
+
+export class EditableGraph extends React.Component<EditableGraphProps, EditableGraphState> {
+  graphRef: React.RefObject<Graph>;
+  mouseDownNode: d3Types.d3Node | null;
+
+  nextId: number = 0;
+
+  readonly compositeLvl: CompositeLevel = new CompositeLevel();
+  readonly versionRegistry: VersionRegistry = new VersionRegistry();
+  currentVersion: IVersion;
+
+  constructor(props) {
+    super(props);
+    this.graphRef = React.createRef<Graph>();
+    this.mouseDownNode = null;
+    this.state = {};
+    this.currentVersion = initialVersion;
+  }
+
+  mouseDownHandler = (event, {x,y}, node) => {
+    event.stopPropagation();
+    console.log("DOWN:", node, event);
+    if (node) {
+      this.mouseDownNode = node;
+    }
+  }
+
+  mouseUpHandler = (event, {x,y}, node) => {
+    event.stopPropagation();
+    console.log("UP:", node, event);
+    if (this.graphRef.current !== null) {
+      if (event.button === 2)  { // right mouse button
+        if (node && this.mouseDownNode) {
+          let label: string|null = null;
+          while (label === null) {
+            label = prompt("Edge label:");
+          }
+          const delta = new EdgeCreation(this.mouseDownNode.obj, label, node.obj);
+          this.graphRef.current.createLink({source: this.mouseDownNode, label, target: node, obj: delta});
+
+          const tx = this.compositeLvl.createComposite([delta]);
+          this.currentVersion = this.versionRegistry.createVersion(this.currentVersion, tx);
+          this.props.newDeltaHandler(tx);
+          this.props.newVersionHandler(this.currentVersion);
+        }
+        else { // right mouse button
+          // Create node:
+          const uuid = this.props.getUuid();
+          const delta = new NodeCreation(uuid);
+
+          this.graphRef.current.createNode({id: uuid.value, x, y, color:"darkturquoise", obj:delta});
+
+          const tx = this.compositeLvl.createComposite([delta]);
+          this.currentVersion = this.versionRegistry.createVersion(this.currentVersion, tx);
+          this.props.newDeltaHandler(tx);
+          this.props.newVersionHandler(this.currentVersion);
+        }
+      }
+      else if (event.button === 1) { // middle mouse button
+        if (node) {
+          const delta = new NodeDeletion(node.obj, []);
+          this.graphRef.current.deleteNode(node.id);
+          const tx = this.compositeLvl.createComposite([delta]);
+          this.currentVersion = this.versionRegistry.createVersion(this.currentVersion, tx);
+          this.props.newDeltaHandler(tx);
+          this.props.newVersionHandler(this.currentVersion);
+        }
+      }
+    }
+    this.mouseDownNode = null;
+  }
+
+  render() {
+    // const [opened, setOpened] = React.useState(false);
+    return (
+      <>
+{/*        <Modal opened={this.state.modal} centered onClose={()=>{this.setState(prev=>{modal:false})}}>
+          <Autocomplete label="Label for new edge" data={["x", "y", "z"]}/>
+        </Modal>
+*/}        <Graph
+          ref={this.graphRef}
+          graph={this.props.graph}
+          mouseDownHandler={this.mouseDownHandler}
+          mouseUpHandler={this.mouseUpHandler}
+        />
+      </>
+    );
+  }
+
+
+}

+ 93 - 74
src/frontend/graph.tsx

@@ -7,13 +7,15 @@ import * as d3 from "d3";
 export namespace d3Types {
   export type d3Node = {
     id: string,
-    group: number
+    color: string,
+    obj: any;
   };
 
   export type d3Link = {
     source: any, // initially string, but d3 replaces it by an object (lol)
     target: any, // initially string, but d3 replaces it by an object (lol)
-    value: number
+    label: string
+    obj: any;
   };
 
   export type d3Graph = {
@@ -35,17 +37,32 @@ class Link extends React.Component<{ link: d3Types.d3Link }, {}> {
     d3.select(this.ref.current).data([this.props.link]);
   }
 
+  ticked() {
+    d3.select(this.ref.current)
+      .attr("x1", (d: any) => d.source.x)
+      .attr("y1", (d: any) => d.source.y)
+      .attr("x2", (d: any) => d.target.x)
+      .attr("y2", (d: any) => d.target.y)
+    ;
+  }
+
   render() {
     return <line className="link" ref={this.ref} markerEnd="url(#arrow2)"/>;
   }
 }
 
 class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
+  links: Array<Link | null> = [];
+
+  ticked() {
+    this.links.forEach(l => l ? l.ticked() : null);
+  }
+
   render() {
     const nodeId = sourceOrTarget => sourceOrTarget.id ? sourceOrTarget.id : sourceOrTarget;
     const key = link => 's'+nodeId(link.source)+'t'+nodeId(link.target);
     const links = this.props.links.map((link: d3Types.d3Link, index: number) => {
-      return <Link key={key(link)} link={link} />;
+      return <Link ref={link => this.links.push(link)} key={key(link)} link={link} />;
     });
 
     return (
@@ -58,7 +75,6 @@ class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
 
 interface NodeProps {
   node: d3Types.d3Node;
-  color: string;
   simulation: any;
   mouseDownHandler: (event) => void;
   mouseUpHandler: (event) => void;
@@ -66,19 +82,14 @@ interface NodeProps {
 
 
 class Node extends React.Component<NodeProps, {}> {
-  ref: React.RefObject<SVGCircleElement>;
-
-  constructor(props) {
-    super(props);
-    this.ref = React.createRef<SVGCircleElement>();
-  }
+  ref: React.RefObject<SVGCircleElement> = React.createRef<SVGCircleElement>();
 
   componentDidMount() {
     d3.select(this.ref.current).data([this.props.node]);
 
     const onDragStart = (event, d: any) => {
       if (!event.active) {
-        this.props.simulation.alphaTarget(0.3).restart();
+        this.props.simulation.alphaTarget(0.1).restart();
       }
       d.fx = d.x;
       d.fy = d.y;
@@ -104,9 +115,15 @@ class Node extends React.Component<NodeProps, {}> {
     }
   }
 
-  render() {
-    console.log("Node.render");
+  ticked() {
+    if (this.ref.current !== null)
+    d3.select(this.ref.current)
+      .attr("cx", (d: any) => d.x)
+      .attr("cy", (d: any) => d.y)
+    ;
+  }
 
+  render() {
     return (
       <circle
         ref={this.ref}
@@ -114,7 +131,7 @@ class Node extends React.Component<NodeProps, {}> {
         onMouseDown={this.props.mouseDownHandler}
         onMouseUp={this.props.mouseUpHandler}
         r={5}
-        fill={this.props.color}
+        fill={this.props.node.color}
       >
         <title>{this.props.node.id}</title>
       </circle>
@@ -130,22 +147,22 @@ interface NodesProps {
 }
 
 class Nodes extends React.Component<NodesProps, {}> {
-  ref: React.RefObject<SVGGElement>;
+  ref: React.RefObject<SVGGElement> = React.createRef<SVGGElement>();
+  nodes: Array<Node | null> = [];
 
-  constructor(props) {
-    super(props);
-    console.log("construct Nodes")
-    this.ref = React.createRef<SVGGElement>();
+  ticked() {
+    this.nodes.forEach(n => n ? n.ticked() : null);
   }
 
   render() {
-    const color = d3.scaleOrdinal(d3.schemeCategory10);
+    this.nodes = [];
     const nodes = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
       return <Node
           key={node.id}
+          ref={node => this.nodes.push(node)}
           mouseDownHandler={event => this.props.mouseDownHandler(event, node)}
           mouseUpHandler={event => this.props.mouseUpHandler(event, node)}
-          node={node} color={color(node.group.toString())} simulation={this.props.simulation} />;
+          node={node} simulation={this.props.simulation} />;
     });
 
     return (
@@ -168,6 +185,12 @@ class Label extends React.Component<{ node: d3Types.d3Node }, {}> {
     d3.select(this.ref.current).data([this.props.node]);
   }
 
+  ticked() {
+    d3.select(this.ref.current)
+      .attr("x", (d: any) => d.x + 10)
+      .attr("y", (d: any) => d.y + 5);
+  }
+
   render() {
     return <text className="label" ref={this.ref}>
       {this.props.node.id}
@@ -176,9 +199,15 @@ class Label extends React.Component<{ node: d3Types.d3Node }, {}> {
 }
 
 class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
+  labels: Array<Label | null> = [];
+
+  ticked() {
+    this.labels.forEach(l => l ? l.ticked() : null);
+  }
+
   render() {
     const labels = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
-      return <Label key={node.id} node={node} />;
+      return <Label ref={label => this.labels.push(label)} key={node.id} node={node} />;
     });
 
     return (
@@ -192,7 +221,6 @@ class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
 
 export interface GraphProps {
   graph: d3Types.d3Graph;
-  // clickHandler?: (event:React.SyntheticEvent, svgCoords: {svgX: number, svgY: number}) => void;
   mouseDownHandler: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: d3Types.d3Node) => void;
   mouseUpHandler: (e: React.SyntheticEvent, svgCoords: {x: number, y: number}, node?: d3Types.d3Node) => void;
 }
@@ -200,12 +228,16 @@ export interface GraphProps {
 interface GraphState {
   nodes: d3Types.d3Node[],
   links: d3Types.d3Link[],
+  panX: number;
+  panY: number;
 }
 
-export class Graph extends React.Component<GraphProps, {}> {
+export class Graph extends React.Component<GraphProps, GraphState> {
   simulation: any;
   refSVG: React.RefObject<SVGSVGElement>;
-  state: GraphState;
+  refNodes: React.RefObject<Nodes> = React.createRef<Nodes>();
+  refLabels: React.RefObject<Labels> = React.createRef<Labels>();
+  refLinks: React.RefObject<Links> = React.createRef<Links>();
 
   link: any;
   node: any;
@@ -216,6 +248,8 @@ export class Graph extends React.Component<GraphProps, {}> {
     this.state = {
       nodes: props.graph.nodes,
       links: props.graph.links,
+      panX: 0,
+      panY: 0,
     };
     this.simulation = d3.forceSimulation()
       .force("link", d3.forceLink().id((d: any) => d.id))
@@ -226,35 +260,42 @@ export class Graph extends React.Component<GraphProps, {}> {
       .nodes(this.state.nodes);
 
     this.refSVG = React.createRef();
-    // this.simulation.force("link").links(this.props.graph.links);
   }
 
-  createNode = (id, svgX,svgY) => {
+  createNode = ({id, x, y, color, obj}) => {
     this.setState((prevState: GraphState) => ({
-      nodes: [...prevState.nodes, { id, group: 8, x: svgX, y: svgY }],
+      nodes: [...prevState.nodes, {id, color, x, y, obj}],
     }));
   }
 
   deleteNode = (id) => {
     this.setState((prevState: GraphState) => {
-      console.log("DELETING NODE", id)
-      console.log("PREVSTATE:",prevState);
       const newLinks = prevState.links.filter(l => l.source.id !== id && l.target.id !== id);
-      console.log("NEWLINKS:",newLinks)
-      return ({
-      nodes: prevState.nodes.filter(n => n.id !== id),
-      links: newLinks,
-    })})
+      return {
+        nodes: prevState.nodes.filter(n => n.id !== id),
+        links: newLinks,
+      };
+    });
   }
 
-  createLink = (source, target) => {
+  createLink = ({source, label, target, obj}) => {
     this.setState((prevState: GraphState) => ({
-      links: [...prevState.links, {source, target, value: 1}],
+      links: [...prevState.links, {source, target, label, obj}],
     }));
   }
 
+  deleteLink = (source, label) => {
+    this.setState((prevState: GraphState) => {
+      const newLinks = prevState.links.slice();
+      newLinks.findIndex(l => l.source.id === source && l.label === label);
+      console.log(newLinks);
+      return {
+        links: newLinks,
+      };
+    });
+  } 
+
   render() {
-    console.log("Graph.render")
     const { graph } = this.props;
 
     const width = 600;
@@ -278,7 +319,7 @@ export class Graph extends React.Component<GraphProps, {}> {
       <svg className="container"
         ref={this.refSVG}
         style={{width: "100%", height}}
-        viewBox={`${-width/2} ${-height/2} ${width} ${height}`}
+        viewBox={`${-width/2+this.state.panX} ${-height/2+this.state.panY} ${width} ${height}`}
         onMouseDown={e => clientToSvgCoords(e, this.props.mouseDownHandler)}
         onMouseUp={e => clientToSvgCoords(e, this.props.mouseUpHandler)}
         onContextMenu={e => e.preventDefault()}
@@ -286,13 +327,13 @@ export class Graph extends React.Component<GraphProps, {}> {
 
         <defs>
           <marker id="arrow2" markerWidth="10" markerHeight="10" refX="14" refY="3" orient="auto" markerUnits="strokeWidth">
-            <path d="M0,0 L0,6 L9,3 z" fill="#fff" />
+            <path d="M0,0 L0,6 L9,3 z" className="arrowHead" />
           </marker>
         </defs>
 
-        <Links links={this.state.links} />
-        <Labels nodes={this.state.nodes} />
-        <Nodes nodes={this.state.nodes} simulation={this.simulation}
+        <Links ref={this.refLinks} links={this.state.links} />
+        <Labels ref={this.refLabels} nodes={this.state.nodes} />
+        <Nodes ref={this.refNodes} nodes={this.state.nodes} simulation={this.simulation}
           mouseDownHandler={(e, node) => clientToSvgCoords(e, (e,coords) => this.props.mouseDownHandler(e,coords,node))}
           mouseUpHandler={(e, node) => clientToSvgCoords(e, (e,coords) => this.props.mouseUpHandler(e,coords,node))}
         />
@@ -310,35 +351,14 @@ export class Graph extends React.Component<GraphProps, {}> {
   }
 
   ticked = () => {
-    this.link
-      .attr("x1", function (d: any) {
-        return d.source.x;
-      })
-      .attr("y1", function (d: any) {
-        return d.source.y;
-      })
-      .attr("x2", function (d: any) {
-        return d.target.x;
-      })
-      .attr("y2", function (d: any) {
-        return d.target.y;
-      });
-
-    this.node
-      .attr("cx", function (d: any) {
-        return d.x;
-      })
-      .attr("cy", function (d: any) {
-        return d.y;
-      });
-
-    this.label
-      .attr("x", function (d: any) {
-        return d.x + 10;
-      })
-      .attr("y", function (d: any) {
-        return d.y + 5;
-      });    
+    if (this.refLinks.current !== null)
+      this.refLinks.current.ticked();
+
+    if (this.refNodes.current !== null)
+      this.refNodes.current.ticked();
+
+    if (this.refLabels.current !== null)
+      this.refLabels.current.ticked();
   }
 
   componentDidMount() {
@@ -347,9 +367,8 @@ export class Graph extends React.Component<GraphProps, {}> {
   }
 
   componentDidUpdate() {
-    console.log("UDPATED STATE:",this.state)
     this.update();
-    this.simulation.alpha(1).restart().tick();
+    this.simulation.alpha(0.1).restart().tick();
     this.ticked();
   }
 }

+ 0 - 63
src/frontend/interactive_graph.tsx

@@ -1,63 +0,0 @@
-import * as React from 'react';
-import {d3Types, Graph, GraphProps} from "./graph";
-
-interface InteractiveGraphProps {
-  graph: d3Types.d3Graph;
-}
-
-export class InteractiveGraph extends React.Component<InteractiveGraphProps, {}> {
-
-  graphRef: React.RefObject<Graph>;
-  mouseDownNode: d3Types.d3Node | null;
-
-  nextId: number = 0;
-
-  constructor(props) {
-    super(props);
-    this.graphRef = React.createRef<Graph>();
-    this.mouseDownNode = null;
-  }
-
-  mouseDownHandler = (event, {x,y}, node) => {
-    event.stopPropagation();
-    console.log("DOWN:", node, event);
-    if (node) {
-      this.mouseDownNode = node;
-    }
-  }
-
-  mouseUpHandler = (event, {x,y}, node) => {
-    event.stopPropagation();
-    console.log("UP:", node, event);
-    if (this.graphRef.current !== null) {
-      if (event.button === 2)  { // right mouse button
-        if (node && this.mouseDownNode) {
-          this.graphRef.current.createLink(this.mouseDownNode, node);
-        }
-        else  { // right mouse button
-          this.graphRef.current.createNode(`ID-${this.nextId++}`, x, y);
-        }
-      }
-      else if (event.button === 1) { // middle mouse button
-        if (node) {
-          console.log("INVOKE ON CHILD")
-          this.graphRef.current.deleteNode(node.id);
-        }
-      }
-    }
-    this.mouseDownNode = null;
-  }
-
-  render() {
-    return (
-      <Graph
-        ref={this.graphRef}
-        graph={this.props.graph}
-        mouseDownHandler={this.mouseDownHandler}
-        mouseUpHandler={this.mouseUpHandler}
-      />
-    );
-  }
-
-
-}

+ 1 - 0
src/onion/buffer_xor.ts

@@ -1,3 +1,4 @@
+import {Buffer} from "buffer";
 
 // Precondition that is NOT CHECKED: buffers must be of equal length
 // Returns new buffer that is bitwise XOR of inputs.

+ 5 - 3
src/onion/composite_delta.ts

@@ -1,12 +1,14 @@
 import {createHash} from "crypto";
 import {Delta} from "./delta";
 
-class CompositeDelta implements Delta {
+export class CompositeDelta implements Delta {
+  readonly deltas: Array<Delta>;
   readonly dependencies: Array<CompositeDelta>;
   readonly conflicts: Array<CompositeDelta>;
   readonly hash: Buffer;
 
-  constructor(dependencies: Array<CompositeDelta>, conflicts: Array<CompositeDelta>, hash: Buffer) {
+  constructor(deltas: Array<Delta>, dependencies: Array<CompositeDelta>, conflicts: Array<CompositeDelta>, hash: Buffer) {
+    this.deltas = deltas;
     this.dependencies = dependencies;
     this.conflicts = conflicts;
     this.hash = hash;
@@ -64,7 +66,7 @@ export class CompositeLevel {
     for (const delta of deltas) {
       hash.update(delta.getHash());
     }
-    const composite = new CompositeDelta(dependencies, conflicts, hash.digest());
+    const composite = new CompositeDelta(deltas, dependencies, conflicts, hash.digest());
 
     for (const delta of deltas) {
       this.containedBy.set(delta, composite);

+ 10 - 9
src/onion/version.test.ts

@@ -2,6 +2,7 @@ import * as _ from "lodash";
 
 import {
   IVersion,
+  initialVersion,
   VersionRegistry,
 } from "./version";
 
@@ -30,10 +31,10 @@ describe("Version", () => {
     const nodeCreation = new NodeCreation(getId());
     const nodeDeletion = new NodeDeletion(nodeCreation, []);
 
-    const version1 = registry.createVersion(registry.initialVersion, nodeCreation);
+    const version1 = registry.createVersion(initialVersion, nodeCreation);
     const version2 = registry.createVersion(version1, nodeDeletion);
 
-    assert(_.isEqual([... registry.initialVersion], []), "expected initialVersion to be empty");
+    assert(_.isEqual([... initialVersion], []), "expected initialVersion to be empty");
     assert(_.isEqual([... version1], [nodeCreation]), "expected version1 to contain creation");
     assert(_.isEqual([... version2], [nodeDeletion, nodeCreation]), "expected version2 to contain creation and deletion");
   });
@@ -45,10 +46,10 @@ describe("Version", () => {
     const nodeCreationA = new NodeCreation(getId());
     const nodeCreationB = new NodeCreation(getId());
 
-    const versionA = registry.createVersion(registry.initialVersion, nodeCreationA);
+    const versionA = registry.createVersion(initialVersion, nodeCreationA);
     const versionAB = registry.createVersion(versionA, nodeCreationB);
 
-    const versionB = registry.createVersion(registry.initialVersion, nodeCreationB);
+    const versionB = registry.createVersion(initialVersion, nodeCreationB);
     const versionBA = registry.createVersion(versionB, nodeCreationA);
 
     assert(versionAB === versionBA, "expected versions to be equal");
@@ -76,7 +77,7 @@ describe("Version", () => {
     assert(intersection2 === v1, "expected intersection of v1 with itself to be v1");
 
     const intersection3 = registry.getIntersection([]);
-    assert(intersection3 === registry.initialVersion, "expected intersection of empty set to be initial (empty) version");
+    assert(intersection3 === initialVersion, "expected intersection of empty set to be initial (empty) version");
   });
 
   // Helper
@@ -90,7 +91,7 @@ describe("Version", () => {
   it("Merge empty set", () => {
     const registry = new VersionRegistry();
     const merged = registry.merge([], new Map());
-    assert(merged.length === 1 && merged[0] === registry.initialVersion, "expected intial version");
+    assert(merged.length === 1 && merged[0] === initialVersion, "expected intial version");
 
     mergeAgain(registry, merged, new Map());
   })
@@ -102,8 +103,8 @@ describe("Version", () => {
     const nodeCreationA = new NodeCreation(getId());
     const nodeCreationB = new NodeCreation(getId());
 
-    const versionA = registry.createVersion(registry.initialVersion, nodeCreationA);
-    const versionB = registry.createVersion(registry.initialVersion, nodeCreationB);
+    const versionA = registry.createVersion(initialVersion, nodeCreationA);
+    const versionB = registry.createVersion(initialVersion, nodeCreationB);
 
     const nameMap = new Map([[nodeCreationA, "A"], [nodeCreationB, "B"]]);
 
@@ -170,7 +171,7 @@ describe("Version", () => {
       nameMap.set(delta, i.toString());
     }
     // Create a version for each delta, containing only that delta:
-    const versions = deltas.map(d => registry.createVersion(registry.initialVersion, d));
+    const versions = deltas.map(d => registry.createVersion(initialVersion, d));
 
     const merged = registry.merge(versions, nameMap);
     assert(merged.length === 1, "only one merged version should result");

+ 9 - 7
src/onion/version.ts

@@ -1,4 +1,5 @@
 import {inspect} from "util"; // NodeJS library
+import { Buffer } from "buffer"; // NodeJS library
 
 import * as _ from "lodash";
 
@@ -51,16 +52,17 @@ class Version implements IVersion {
   }
 }
 
+// The initial, empty version.
+const initialHash = Buffer.alloc(32); // all zeros
+export const initialVersion = new Version([], initialHash, 0);
+
+
 export class VersionRegistry {
   // Maps version ID (as string, because a Buffer cannot be a map key) to Version
   readonly versionMap: Map<string, Version> = new Map();
-  // The initial, empty version.
-  readonly initialVersion: Version;
 
   constructor() {
-    const initialHash = Buffer.alloc(32); // all zeros
-    this.initialVersion = new Version([], initialHash, 0);
-    this.versionMap.set(initialHash.toString('base64'), this.initialVersion);
+    this.versionMap.set(initialHash.toString('base64'), initialVersion);
   }
 
   private lookupOptional(hash: Buffer): Version | undefined {
@@ -117,14 +119,14 @@ export class VersionRegistry {
   // Order of deltas should be recent -> early
   // Or put more precisely: a delta's dependencies should occur AFTER the delta in the array.
   quickVersion(deltas: Array<Delta>): Version {
-    return deltas.reduceRight((parentVersion, delta) => this.createVersion(parentVersion, delta), this.initialVersion);
+    return deltas.reduceRight((parentVersion, delta) => this.createVersion(parentVersion, delta), initialVersion);
   }
 
   // Get the version whose deltas are a subset of all given versions. This is typically the 'common parent'.
   getIntersection(versions: Array<Version>): Version {
     // treat special case first:
     if (versions.length === 0) {
-      return this.initialVersion;
+      return initialVersion;
     }
 
     // sort versions (out place) from few deltas to many (FASTEST):

+ 7 - 1
webpack.config.js

@@ -15,7 +15,13 @@ module.exports = {
   resolve: {
     extensions: ['.tsx', '.ts', '.js'],
     fallback: {
-      util: path.resolve(__dirname, 'src', 'onion', 'mock_nodejs_util.ts'),
+      util: path.resolve(__dirname, 'src', 'onion', 'mock_node_util.ts'),
+
+      // The following are needed to make NodeJS' 'crypto' module work in the browser:
+      // Should probably migrate to the SubtleCrypto Web API
+      crypto: require.resolve('crypto-browserify'),
+      stream: require.resolve('stream-browserify'),
+      buffer: require.resolve('buffer'),
     },
   },
   output: {