Browse Source

Frontend: Managed to get D3 to work with React.

Joeri Exelmans 3 years ago
parent
commit
882195d790
9 changed files with 1349 additions and 239 deletions
  1. 30 20
      dist/index.html
  2. 8 0
      package.json
  3. 567 24
      pnpm-lock.yaml
  4. 417 0
      src/frontend/app.tsx
  5. 308 0
      src/frontend/graph_view.tsx
  6. 0 192
      src/frontend/index.js
  7. 13 0
      src/frontend/index.tsx
  8. 5 2
      tsconfig.json
  9. 1 1
      webpack.config.js

+ 30 - 20
dist/index.html

@@ -1,34 +1,44 @@
+<!doctype html>
 <html>
   <head>
     <meta charset="UTF-8"/>
+    <title>Onion VCS</title>
     <style>
       body {
-        background-color: #ddd;
+        margin: 0;
+        
+         -webkit-user-select: none;
+         -moz-user-select: none;
+         -ms-user-select: none;
+         user-select: none;
       }
-      svg {
-        background-color: white;
+
+      svg text{
+      }
+
+      .container {
+        background-color: #333;
       }
-      textarea {
-        background-color: #eee;
+
+      .node {
+        stroke: #fff;
+        stroke-width: 1.5px;
+      }
+
+      .link {
+        stroke: #999;
+        stroke-opacity: 1;
       }
+
+      .label {
+        font-size: 10px;
+        fill: #fff;
+      }
+
     </style>
   </head>
   <body>
-    <svg width="600" height="400" id="svg">
-      <defs>
-        <marker id="arrowhead" markerWidth="10" markerHeight="7" 
-        refX="0" refY="3.5" orient="auto">
-          <polygon points="0 0, 10 3.5, 0 7" />
-        </marker>
-      </defs>
-    </svg>
-    <textarea rows="26" cols="40" readonly id="log"></textarea>
-    <br/>
-
-    <button id="createNode">Create node</button>
-    <br/>
-    Left-click on a node to delete it. Drag with the right mouse button from one node to another to create an edge.
-
+    <div id="root"></div>
     <script type="module" src="./bundle.js"></script>
   </body>
 </html>

+ 8 - 0
package.json

@@ -9,16 +9,24 @@
 		"webpack-dev-server": "^4.9.3"
 	},
 	"dependencies": {
+		"@emotion/react": "^11.10.0",
+		"@mantine/core": "^5.2.3",
+		"@mantine/hooks": "^5.2.3",
 		"@types/d3": "^7.4.0",
 		"@types/d3-drag": "^3.0.1",
 		"@types/d3-force": "^3.0.3",
 		"@types/d3-selection": "^3.0.3",
 		"@types/node": "^18.6.1",
+		"@types/react": "^18.0.17",
+		"@types/react-dom": "^18.0.6",
 		"d3": "^7.6.1",
 		"d3-drag": "^3.0.0",
 		"d3-force": "^3.0.0",
+		"d3-scale": "^4.0.2",
 		"d3-selection": "^3.0.0",
 		"lodash": "^4.17.21",
+		"react": "^18.2.0",
+		"react-dom": "^18.2.0",
 		"ts-node": "^10.9.1",
 		"typescript": "^4.7.4"
 	},

File diff suppressed because it is too large
+ 567 - 24
pnpm-lock.yaml


+ 417 - 0
src/frontend/app.tsx

@@ -0,0 +1,417 @@
+import * as React from "react";
+import * as _ from "lodash";
+
+import {Grid, AppShell, Navbar, Header, Text, Title, MantineProvider} from "@mantine/core";
+import {GraphView} from "./graph_view"
+
+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 }
+  ]
+};
+
+const emptyGraph = {
+  nodes: [],
+  links: [],
+}
+
+const graph2 = {
+  nodes: [
+    {id: "0", group: 0},
+    {id: "1", group: 0},
+    {id: "2", group: 0},
+  ],
+  links: [
+    {source: "0", target: "1", value: 1},
+    {source: "1", target: "2", value: 1},
+    {source: "2", target: "0", value: 1},
+  ],
+}
+
+
+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>
+          <Grid.Col span={1}>
+            <Text>Graph state</Text>
+            <GraphView width={600} height={600} graph={_.cloneDeep(graph)} onClick={console.log} />
+          </Grid.Col>
+          <Grid.Col span={1}>
+            <Text>Version</Text>
+            <GraphView width={600} height={600} graph={_.cloneDeep(emptyGraph)} onClick={console.log} />
+          </Grid.Col>
+          <Grid.Col span={1}>
+            <Text>Delta</Text>
+            <GraphView width={600} height={600} graph={_.cloneDeep(emptyGraph)} onClick={console.log} />
+          </Grid.Col>
+        </Grid>
+    </AppShell>
+    </MantineProvider>
+
+    );
+  }
+}
+ 

+ 308 - 0
src/frontend/graph_view.tsx

@@ -0,0 +1,308 @@
+// Adopted from the following example, and patched to work with React v18 and D3 v7.6:
+// https://github.com/korydondzila/React-TypeScript-D3/tree/master/src
+
+import * as React from 'react';
+import * as d3 from "d3";
+
+namespace d3Types {
+  export type d3Node = {
+    id: string,
+    group: number
+  };
+
+  export type d3Link = {
+    source: string,
+    target: string,
+    value: number
+  };
+
+  export type d3Graph = {
+    nodes: d3Node[],
+    links: d3Link[]
+  };
+}
+
+
+class Link extends React.Component<{ link: d3Types.d3Link }, {}> {
+  ref: React.RefObject<SVGLineElement>;
+
+  constructor(props) {
+    super(props);
+    this.ref = React.createRef<SVGLineElement>();
+  }
+
+  componentDidMount() {
+    d3.select(this.ref.current).data([this.props.link]);
+  }
+
+  render() {
+    return <line className="link" ref={this.ref} marker-end="url(#arrow2)"/>;
+  }
+}
+
+class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
+  render() {
+    const links = this.props.links.map((link: d3Types.d3Link, index: number) => {
+      return <Link key={index} link={link} />;
+    });
+
+    return (
+      <g className="links">
+        {links}
+      </g>
+    );
+  }
+}
+
+
+class Node extends React.Component<{ node: d3Types.d3Node, color: string }, {}> {
+  ref: React.RefObject<SVGCircleElement>;
+
+  constructor(props) {
+    super(props);
+    this.ref = React.createRef<SVGCircleElement>();
+  }
+
+  componentDidMount() {
+    d3.select(this.ref.current).data([this.props.node]);
+  }
+
+  render() {
+    return (
+      <circle className="node" r={5} fill={this.props.color}
+        ref={this.ref}>
+        <title>{this.props.node.id}</title>
+      </circle>
+    );
+  }
+}
+
+class Nodes extends React.Component<{ nodes: d3Types.d3Node[], simulation: any }, {}> {
+  ref: React.RefObject<SVGGElement>;
+  // state: {nodes: d3Types.d3Node[]};
+
+  constructor(props) {
+    super(props);
+    console.log("construct Nodes")
+    this.ref = React.createRef<SVGGElement>();
+    // this.state = {nodes: props.nodes};
+  }
+
+  componentDidMount() {
+  }
+
+  componentDidUpdate() {
+    const simulation = this.props.simulation;
+    d3.select(this.ref.current).selectAll(".node")
+    // @ts-ignore: Doesn't work
+      .call(d3.drag()
+        .on("start", onDragStart)
+        .on("drag", onDrag)
+        .on("end", onDragEnd));
+
+    function onDragStart(event, d: any) {
+      if (!event.active) {
+        simulation.alphaTarget(0.3).restart();
+      }
+      d.fx = d.x;
+      d.fy = d.y;
+    }
+
+    function onDrag(event, d: any) {
+      d.fx = event.x;
+      d.fy = event.y;
+    }
+
+    function onDragEnd(event, d: any) {
+      if (!event.active) {
+        simulation.alphaTarget(0);
+      }
+      d.fx = null;
+      d.fy = null;
+    }    
+  }
+
+  render() {
+    const color = d3.scaleOrdinal(d3.schemeCategory10);
+    const nodes = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
+      return <Node key={index} node={node} color={color(node.group.toString())} />;
+    });
+
+    return (
+      <g ref={this.ref} className="nodes">
+        {nodes}
+      </g>
+    );
+  }
+}
+
+class Label extends React.Component<{ node: d3Types.d3Node }, {}> {
+  ref: React.RefObject<SVGTextElement>;
+
+  constructor(props) {
+    super(props);
+    this.ref = React.createRef<SVGTextElement>();
+  }
+
+  componentDidMount() {
+    d3.select(this.ref.current).data([this.props.node]);
+  }
+
+  render() {
+    return <text className="label" ref={this.ref}>
+      {this.props.node.id}
+    </text>;
+  }
+}
+
+class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
+  render() {
+    const labels = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
+      return <Label key={index} node={node} />;
+    });
+
+    return (
+      <g className="labels">
+        {labels}
+      </g>
+    );
+  }
+}
+
+
+interface Props {
+  width: number;
+  height: number;
+  graph: d3Types.d3Graph;
+  onClick: (SyntheticBaseEvent) => void;
+}
+
+interface GraphViewState {
+  nodes: d3Types.d3Node[],
+  links: d3Types.d3Link[],
+}
+
+export class GraphView extends React.Component<Props, {}> {
+  simulation: any;
+  refSVG: React.RefObject<SVGSVGElement>;
+  state: GraphViewState;
+
+  link: any;
+  node: any;
+  label: any;
+
+  nextId: number = 0;
+
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      nodes: props.graph.nodes,
+      links: props.graph.links,
+    };
+    this.simulation = d3.forceSimulation()
+      .force("link", d3.forceLink().id((d: any) => d.id))
+      .force("charge", d3.forceManyBody().strength(-100))
+      .force("x", d3.forceX().strength(0.1))
+      .force("y", d3.forceY().strength(0.1))
+      // @ts-ignore: Stupid error
+      .nodes(this.state.nodes);
+
+    this.refSVG = React.createRef();
+    // this.simulation.force("link").links(this.props.graph.links);
+  }
+
+  addNode = (id, x,y) => {
+    this.setState((prevState: GraphViewState) => ({
+      nodes: [...prevState.nodes, { id, group: 8, x, y }],
+    }));
+  }
+
+  handleClick = event => {
+    if (event.detail === 2) { // Double-click
+      // Translate mouse event to SVG coordinates:
+      if (this.refSVG.current !== null) {
+        const ctm = this.refSVG.current.getScreenCTM();
+        if (ctm !== null) {
+          const pt = this.refSVG.current.createSVGPoint();
+          pt.x = event.clientX;
+          pt.y = event.clientY;
+          const {x, y} = pt.matrixTransform(ctm.inverse());
+          this.addNode(this.nextId++, x, y);
+        }
+      }      
+    }
+  }
+
+  render() {
+    const { width, height, graph } = this.props;
+
+    return (
+      <svg className="container"
+        ref={this.refSVG}
+        width={"100%"} height={"100%"}
+        viewBox={`${-width/2} ${-height/2} ${width} ${height}`}
+        onClick={this.handleClick}>
+        <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" />
+          </marker>
+        </defs>
+        <Links links={this.state.links} />
+        <Labels nodes={this.state.nodes} />
+        <Nodes nodes={this.state.nodes} simulation={this.simulation} />
+      </svg>
+    );
+  }
+
+  update = () => {
+    this.node = d3.selectAll(".node");
+    this.link = d3.selectAll(".link");
+    this.label = d3.selectAll(".label");
+    this.simulation.nodes(this.state.nodes);
+    this.simulation.force("link").links(this.state.links);
+  }
+
+  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;
+      });    
+  }
+
+  componentDidMount() {
+    this.update();
+    this.simulation.on("tick", () => this.ticked());
+  }
+
+  componentDidUpdate() {
+    this.update();
+    this.simulation.alpha(1).restart().tick();
+    this.ticked();
+  }
+}

File diff suppressed because it is too large
+ 0 - 192
src/frontend/index.js


+ 13 - 0
src/frontend/index.tsx

@@ -0,0 +1,13 @@
+import * as React from 'react';
+import {createRoot} from 'react-dom/client';
+
+import {App} from "./app";
+
+const container = document.getElementById('root');
+const root = createRoot(container!);
+
+root.render(
+  <React.StrictMode>
+    <App/>
+  </React.StrictMode>
+);

+ 5 - 2
tsconfig.json

@@ -1,12 +1,15 @@
 {
   "compilerOptions": {
-    "types": ["mocha", "node"],
+    "types": ["mocha", "node", "react"],
     "target": "es6",
+    "jsx": "react",
+    "sourceMap": true,
     "noImplicitThis": true,
     "strictBindCallApply": true,
     "strictNullChecks": true,
     "strictFunctionTypes": true,
     "strictPropertyInitialization": true,
-    "alwaysStrict": true
+    "alwaysStrict": true,
+    "moduleResolution": "nodenext",
   }
 }

+ 1 - 1
webpack.config.js

@@ -1,7 +1,7 @@
 const path = require('path');
 
 module.exports = {
-  entry: path.resolve(__dirname, 'src', 'frontend', 'index.js'),
+  entry: path.resolve(__dirname, 'src', 'frontend', 'index.tsx'),
   devtool: 'inline-source-map',
   module: {
     rules: [