radial sugiyama positioning integration
This commit is contained in:
@@ -19,6 +19,23 @@ Open: `http://localhost:5173`
|
||||
## Configuration
|
||||
|
||||
- `VITE_BACKEND_URL` controls where `/api/*` is proxied (see `frontend/vite.config.ts`).
|
||||
- The right-side cosmos graph reads these `VITE_...` settings at dev-server startup:
|
||||
- `VITE_COSMOS_ENABLE_SIMULATION`
|
||||
- `VITE_COSMOS_DEBUG_LAYOUT`
|
||||
- `VITE_COSMOS_SIMULATION_REPULSION`
|
||||
- `VITE_COSMOS_SIMULATION_LINK_SPRING`
|
||||
- `VITE_COSMOS_SIMULATION_LINK_DISTANCE`
|
||||
- `VITE_COSMOS_SIMULATION_GRAVITY`
|
||||
- `VITE_COSMOS_SIMULATION_CENTER`
|
||||
- `VITE_COSMOS_SIMULATION_DECAY`
|
||||
- `VITE_COSMOS_SIMULATION_FRICTION`
|
||||
- `VITE_COSMOS_SPACE_SIZE`
|
||||
- `VITE_COSMOS_CURVED_LINKS`
|
||||
- `VITE_COSMOS_FIT_VIEW_PADDING`
|
||||
- The right pane keeps a static camera after an explicit `fitViewByPointPositions(...)` from the current seed positions.
|
||||
- `VITE_COSMOS_SIMULATION_CENTER` is the main knob for keeping the graph mass near the viewport center during force layout.
|
||||
- `VITE_COSMOS_DEBUG_LAYOUT=true` enables a small debug overlay and `console.debug` logs for graph-space centroid/bounds, screen-space origin/centroid placement, zoom, alpha/progress, and space-boundary pressure.
|
||||
- In Docker Compose, set them in the repo-root `.env` and restart the `frontend` service.
|
||||
|
||||
## UI
|
||||
|
||||
|
||||
781
frontend/package-lock.json
generated
781
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "react-vite-tailwind",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cosmos.gl/graph": "^2.6.4",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"clsx": "2.1.1",
|
||||
"react": "19.2.3",
|
||||
@@ -21,6 +22,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.2.4",
|
||||
"vite-plugin-singlefile": "2.3.0"
|
||||
@@ -308,6 +310,31 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cosmos.gl/graph": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz",
|
||||
"integrity": "sha512-i+N9lSpAjGLTUPelo/bKNbQnKPDqt3k2UnRlfIWe2Lrambc4J3QFgOfpR8AalQ/1tgLRoeNtVBZ1GPpsNqae5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.1",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"gl-bench": "^1.0.42",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"random": "^4.1.0",
|
||||
"regl": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.2.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -1511,6 +1538,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
|
||||
@@ -1639,6 +1673,172 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1667,6 +1867,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
@@ -1796,6 +2005,31 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-bench": {
|
||||
"version": "1.0.42",
|
||||
"resolved": "https://registry.npmjs.org/gl-bench/-/gl-bench-1.0.42.tgz",
|
||||
"integrity": "sha512-zuMsA/NCPmI8dPy6q3zTUH8OUM5cqKg7uVWwqzrtXJPBqoypM0XeFWEc8iFOqbf/1qtXieWOrbmgFEByKTQt4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1803,6 +2037,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -2246,6 +2489,18 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/random": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz",
|
||||
"integrity": "sha512-6Ajb7XmMSE9EFAMGC3kg9mvE7fGlBip25mYYuSMzw/uUSrmGilvZo2qwX3RnTRjwXkwkS+4swse9otZ92VjAtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"seedrandom": "^3.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
@@ -2277,6 +2532,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regl": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz",
|
||||
"integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
@@ -2328,6 +2599,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -2409,6 +2686,510 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"layout": "tsx scripts/compute_layout.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cosmos.gl/graph": "^2.6.4",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"clsx": "2.1.1",
|
||||
"react": "19.2.3",
|
||||
@@ -23,9 +24,9 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"typescript": "5.9.3",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.2.4",
|
||||
"vite-plugin-singlefile": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,96 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Renderer } from "./renderer";
|
||||
import { fetchGraphQueries } from "./graph_queries";
|
||||
import type { GraphQueryMeta } from "./graph_queries";
|
||||
import { fetchSelectionQueries, runSelectionQuery } from "./selection_queries";
|
||||
import type { GraphMeta, SelectionQueryMeta } from "./selection_queries";
|
||||
import { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./selection_queries";
|
||||
import { cosmosRuntimeConfig } from "./cosmos_config";
|
||||
import type { GraphMeta, GraphRoutePoint, GraphRouteSegment, SelectionQueryMeta, SelectionTriple } from "./selection_queries";
|
||||
import { TripleGraphView } from "./TripleGraphView";
|
||||
import { buildTripleGraphModel, type TripleGraphModel } from "./triple_graph";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
type GraphNodeMeta = {
|
||||
id?: number;
|
||||
iri?: string;
|
||||
label?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
function graphRoutePoint(value: unknown): GraphRoutePoint | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.x !== "number" || typeof record.y !== "number") return null;
|
||||
return {
|
||||
x: record.x,
|
||||
y: record.y,
|
||||
};
|
||||
}
|
||||
|
||||
function graphRouteSegmentArray(value: unknown): GraphRouteSegment[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: GraphRouteSegment[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.edge_index !== "number" || typeof record.kind !== "string") continue;
|
||||
if (!Array.isArray(record.points)) continue;
|
||||
const points: GraphRoutePoint[] = [];
|
||||
for (const point of record.points) {
|
||||
const parsed = graphRoutePoint(point);
|
||||
if (!parsed) continue;
|
||||
points.push(parsed);
|
||||
}
|
||||
if (points.length < 2) continue;
|
||||
out.push({
|
||||
edge_index: record.edge_index,
|
||||
kind: record.kind,
|
||||
points,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildRouteLineVertices(routeSegments: GraphRouteSegment[]): Float32Array {
|
||||
let lineCount = 0;
|
||||
for (const route of routeSegments) {
|
||||
lineCount += Math.max(0, route.points.length - 1);
|
||||
}
|
||||
|
||||
const out = new Float32Array(lineCount * 4);
|
||||
let offset = 0;
|
||||
for (const route of routeSegments) {
|
||||
for (let i = 1; i < route.points.length; i++) {
|
||||
const previous = route.points[i - 1];
|
||||
const current = route.points[i];
|
||||
out[offset++] = previous.x;
|
||||
out[offset++] = previous.y;
|
||||
out[offset++] = current.x;
|
||||
out[offset++] = current.y;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type TripleResultState = {
|
||||
status: "idle" | "loading" | "ready" | "error";
|
||||
queryId: string;
|
||||
selectedIds: number[];
|
||||
triples: SelectionTriple[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function idleTripleResult(queryId: string): TripleResultState {
|
||||
return {
|
||||
status: "idle",
|
||||
queryId,
|
||||
selectedIds: [],
|
||||
triples: [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rendererRef = useRef<Renderer | null>(null);
|
||||
@@ -28,14 +111,17 @@ export default function App() {
|
||||
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
|
||||
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
|
||||
const [activeSelectionQueryId, setActiveSelectionQueryId] = useState<string>("neighbors");
|
||||
const [tripleResult, setTripleResult] = useState<TripleResultState>(() => idleTripleResult("neighbors"));
|
||||
const [tripleGraphModel, setTripleGraphModel] = useState<TripleGraphModel | null>(null);
|
||||
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
|
||||
const graphMetaRef = useRef<GraphMeta | null>(null);
|
||||
const selectionReqIdRef = useRef(0);
|
||||
const tripleReqIdRef = useRef(0);
|
||||
const graphInitializedRef = useRef(false);
|
||||
|
||||
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
||||
const mousePos = useRef({ x: 0, y: 0 });
|
||||
const nodesRef = useRef<any[]>([]);
|
||||
const nodesRef = useRef<GraphNodeMeta[]>([]);
|
||||
|
||||
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
|
||||
const renderer = rendererRef.current;
|
||||
@@ -60,11 +146,13 @@ export default function App() {
|
||||
|
||||
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
||||
const edges = Array.isArray(graph.edges) ? graph.edges : [];
|
||||
const routeSegments = graphRouteSegmentArray(graph.route_segments);
|
||||
const meta = graph.meta || null;
|
||||
const count = nodes.length;
|
||||
|
||||
nodesRef.current = nodes;
|
||||
graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null;
|
||||
setTripleResult(idleTripleResult(activeSelectionQueryId));
|
||||
|
||||
// Build positions from backend-provided node coordinates.
|
||||
setStatus("Preparing buffers…");
|
||||
@@ -90,6 +178,7 @@ export default function App() {
|
||||
edgeData[i * 2] = typeof s === "number" ? s >>> 0 : 0;
|
||||
edgeData[i * 2 + 1] = typeof t === "number" ? t >>> 0 : 0;
|
||||
}
|
||||
const routeLineVertices = buildRouteLineVertices(routeSegments);
|
||||
|
||||
// Use /api/graph meta; don't do a second expensive backend call.
|
||||
if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") {
|
||||
@@ -106,13 +195,32 @@ export default function App() {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
if (signal.aborted) return;
|
||||
|
||||
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
||||
const buildMs = renderer.init(
|
||||
xs,
|
||||
ys,
|
||||
vertexIds,
|
||||
edgeData,
|
||||
routeLineVertices.length > 0 ? routeLineVertices : null
|
||||
);
|
||||
setNodeCount(renderer.getNodeCount());
|
||||
setSelectedNodes(new Set());
|
||||
setStatus("");
|
||||
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edges.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
function getSelectedIds(renderer: Renderer, selected: Set<number>): number[] {
|
||||
const selectedIds: number[] = [];
|
||||
for (const sortedIdx of selected) {
|
||||
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
|
||||
if (origIdx === null) continue;
|
||||
const node = nodesRef.current?.[origIdx];
|
||||
const nodeId = node?.id;
|
||||
if (typeof nodeId !== "number") continue;
|
||||
selectedIds.push(nodeId);
|
||||
}
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -186,14 +294,14 @@ export default function App() {
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Input handling ──
|
||||
// Input handling
|
||||
let dragging = false;
|
||||
let didDrag = false; // true if mouse moved significantly during drag
|
||||
let didDrag = false;
|
||||
let downX = 0;
|
||||
let downY = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
const DRAG_THRESHOLD = 5; // pixels
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const onDown = (e: MouseEvent) => {
|
||||
dragging = true;
|
||||
@@ -207,7 +315,6 @@ export default function App() {
|
||||
mousePos.current = { x: e.clientX, y: e.clientY };
|
||||
if (!dragging) return;
|
||||
|
||||
// Check if we've moved enough to consider it a drag
|
||||
const dx = e.clientX - downX;
|
||||
const dy = e.clientY - downY;
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
@@ -220,15 +327,14 @@ export default function App() {
|
||||
};
|
||||
const onUp = (e: MouseEvent) => {
|
||||
if (dragging && !didDrag) {
|
||||
// This was a click, not a drag - handle selection
|
||||
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
||||
if (node) {
|
||||
setSelectedNodes((prev: Set<number>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.index)) {
|
||||
next.delete(node.index); // Deselect if already selected
|
||||
next.delete(node.index);
|
||||
} else {
|
||||
next.add(node.index); // Select
|
||||
next.add(node.index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -252,7 +358,7 @@ export default function App() {
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
canvas.addEventListener("mouseleave", onMouseLeave);
|
||||
|
||||
// ── Render loop ──
|
||||
// Render loop
|
||||
let frameCount = 0;
|
||||
let lastTime = performance.now();
|
||||
let raf = 0;
|
||||
@@ -261,7 +367,6 @@ export default function App() {
|
||||
const result = renderer.render();
|
||||
frameCount++;
|
||||
|
||||
// Find hovered node using quadtree
|
||||
const hit = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
||||
if (hit) {
|
||||
const origIdx = renderer.sortedIndexToOriginalIndex(hit.index);
|
||||
@@ -328,44 +433,30 @@ export default function App() {
|
||||
return () => ctrl.abort();
|
||||
}, [activeGraphQueryId]);
|
||||
|
||||
// Sync selection state to renderer
|
||||
// Left-side selection highlighting path
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer) return;
|
||||
|
||||
// Optimistically reflect selection immediately; highlights will be filled in by backend.
|
||||
renderer.updateSelection(selectedNodes, new Set());
|
||||
|
||||
// Invalidate any in-flight request for the previous selection/mode.
|
||||
const reqId = ++selectionReqIdRef.current;
|
||||
|
||||
// Convert selected sorted indices to backend node IDs (graph-export dense IDs).
|
||||
const selectedIds: number[] = [];
|
||||
for (const sortedIdx of selectedNodes) {
|
||||
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
|
||||
if (origIdx === null) continue;
|
||||
const n = nodesRef.current?.[origIdx];
|
||||
const nodeId = n?.id;
|
||||
if (typeof nodeId !== "number") continue;
|
||||
selectedIds.push(nodeId);
|
||||
}
|
||||
const selectedIds = getSelectedIds(renderer, selectedNodes);
|
||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
|
||||
const ctrl = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const neighborIds = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||
const result = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== selectionReqIdRef.current) return;
|
||||
|
||||
const neighborSorted = new Set<number>();
|
||||
for (const id of neighborIds) {
|
||||
for (const id of result.neighborIds) {
|
||||
if (typeof id !== "number") continue;
|
||||
const sorted = renderer.vertexIdToSortedIndexOrNull(id);
|
||||
if (sorted === null) continue;
|
||||
@@ -375,8 +466,8 @@ export default function App() {
|
||||
renderer.updateSelection(selectedNodes, neighborSorted);
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== selectionReqIdRef.current) return;
|
||||
console.warn(e);
|
||||
// Keep the UI usable even if neighbors fail to load.
|
||||
renderer.updateSelection(selectedNodes, new Set());
|
||||
}
|
||||
})();
|
||||
@@ -384,213 +475,369 @@ export default function App() {
|
||||
return () => ctrl.abort();
|
||||
}, [selectedNodes, activeSelectionQueryId]);
|
||||
|
||||
// Right-side triple graph path
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer) return;
|
||||
|
||||
const reqId = ++tripleReqIdRef.current;
|
||||
const selectedIds = getSelectedIds(renderer, selectedNodes);
|
||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
setTripleResult(idleTripleResult(queryId));
|
||||
return;
|
||||
}
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setTripleResult({
|
||||
status: "loading",
|
||||
queryId,
|
||||
selectedIds,
|
||||
triples: [],
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await runSelectionTripleQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== tripleReqIdRef.current) return;
|
||||
|
||||
setTripleResult({
|
||||
status: "ready",
|
||||
queryId: result.queryId,
|
||||
selectedIds: result.selectedIds,
|
||||
triples: result.triples,
|
||||
});
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== tripleReqIdRef.current) return;
|
||||
console.warn(e);
|
||||
setTripleResult({
|
||||
status: "error",
|
||||
queryId,
|
||||
selectedIds,
|
||||
triples: [],
|
||||
errorMessage: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [selectedNodes, activeSelectionQueryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tripleResult.status !== "ready") {
|
||||
setTripleGraphModel(null);
|
||||
return;
|
||||
}
|
||||
setTripleGraphModel(buildTripleGraphModel(tripleResult.triples, tripleResult.selectedIds));
|
||||
}, [tripleResult]);
|
||||
|
||||
const resultQueryId = (tripleResult.queryId || activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
const resultQueryLabel = selectionQueries.find((q) => q.id === resultQueryId)?.label ?? resultQueryId;
|
||||
|
||||
return (
|
||||
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
/>
|
||||
<div style={{ width: "100vw", height: "100vh", display: "flex", overflow: "hidden", background: "#000" }}>
|
||||
<div style={{ position: "relative", flex: "1 1 50%", minWidth: 0, background: "#000" }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{status && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#0f0",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error overlay */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#f44",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HUD */}
|
||||
{!status && !error && (
|
||||
<>
|
||||
{status && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#0f0",
|
||||
fontFamily: "monospace",
|
||||
padding: "8px 12px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.6",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
<div>FPS: {stats.fps}</div>
|
||||
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||
<div>Mode: {stats.mode}</div>
|
||||
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||
{backendStats && (
|
||||
<div style={{ color: "#8f8" }}>
|
||||
Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
|
||||
</div>
|
||||
)}
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
color: "#888",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#f44",
|
||||
fontFamily: "monospace",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
Drag to pan · Scroll to zoom · Click to select
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection query buttons */}
|
||||
{selectionQueries.length > 0 && (
|
||||
{!status && !error && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
color: "#0f0",
|
||||
fontFamily: "monospace",
|
||||
padding: "8px 12px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.6",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{selectionQueries.map((q) => {
|
||||
const active = q.id === activeSelectionQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,255,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,255,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#0ff" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div>FPS: {stats.fps}</div>
|
||||
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||
<div>Mode: {stats.mode}</div>
|
||||
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||
{backendStats && (
|
||||
<div style={{ color: "#8f8" }}>
|
||||
Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph query buttons */}
|
||||
{graphQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
color: "#888",
|
||||
fontFamily: "monospace",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{graphQueries.map((q) => {
|
||||
const active = q.id === activeGraphQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveGraphQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,0,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,0,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#8f8" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
Drag to pan · Scroll to zoom · Click to select
|
||||
</div>
|
||||
|
||||
{selectionQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{selectionQueries.map((q) => {
|
||||
const active = q.id === activeSelectionQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,255,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,255,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#0ff" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{graphQueries.map((q) => {
|
||||
const active = q.id === activeGraphQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveGraphQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,0,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,0,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#8f8" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hoveredNode && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: hoveredNode.screenX + 15,
|
||||
top: hoveredNode.screenY + 15,
|
||||
background: "rgba(0,0,0,0.85)",
|
||||
color: "#0ff",
|
||||
fontFamily: "monospace",
|
||||
padding: "6px 10px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
border: "1px solid rgba(0,255,255,0.3)",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#0ff" }}>
|
||||
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
|
||||
</div>
|
||||
<div style={{ color: "#688" }}>
|
||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 50%",
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "#050505",
|
||||
borderLeft: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "18px 20px 14px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#688", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||
{resultQueryLabel}
|
||||
</div>
|
||||
<div style={{ marginTop: "6px", color: "#eee", fontSize: "16px" }}>Selection Graph</div>
|
||||
<div style={{ marginTop: "8px", color: "#8ab", fontSize: "12px" }}>
|
||||
Nodes: {(tripleGraphModel?.nodeCount ?? 0).toLocaleString()} · Edges: {(tripleGraphModel?.edgeCount ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ marginTop: "4px", color: "#6f8b98", fontSize: "11px" }}>
|
||||
Layout: {cosmosRuntimeConfig.enableSimulation ? "force-directed" : "static"} · Camera: static · Center force: {cosmosRuntimeConfig.simulationCenter} · Repulsion: {cosmosRuntimeConfig.simulationRepulsion} · Link spring: {cosmosRuntimeConfig.simulationLinkSpring} · Friction: {cosmosRuntimeConfig.simulationFriction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
fontFamily: "monospace",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{tripleResult.status === "idle" && (
|
||||
<div
|
||||
style={{
|
||||
color: "#8a8a8a",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
padding: "14px",
|
||||
}}
|
||||
>
|
||||
Select nodes on the left to view returned triples
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hoveredNode && (
|
||||
{tripleResult.status === "loading" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: hoveredNode.screenX + 15,
|
||||
top: hoveredNode.screenY + 15,
|
||||
background: "rgba(0,0,0,0.85)",
|
||||
color: "#0ff",
|
||||
fontFamily: "monospace",
|
||||
padding: "6px 10px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "nowrap",
|
||||
border: "1px solid rgba(0,255,255,0.3)",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||
color: "#8ab",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
padding: "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#0ff" }}>
|
||||
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
|
||||
</div>
|
||||
<div style={{ color: "#688" }}>
|
||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
||||
</div>
|
||||
Running triple query…
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tripleResult.status === "error" && (
|
||||
<div
|
||||
style={{
|
||||
color: "#f88",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
padding: "14px",
|
||||
}}
|
||||
>
|
||||
{tripleResult.errorMessage || "Triple query failed"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tripleResult.status === "ready" && (!tripleGraphModel || tripleGraphModel.edgeCount === 0) && (
|
||||
<div
|
||||
style={{
|
||||
color: "#8a8a8a",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
padding: "14px",
|
||||
}}
|
||||
>
|
||||
No returned graph
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tripleResult.status === "ready" && tripleGraphModel && tripleGraphModel.edgeCount > 0 && (
|
||||
<TripleGraphView model={tripleGraphModel} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
484
frontend/src/TripleGraphView.tsx
Normal file
484
frontend/src/TripleGraphView.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Graph, type GraphConfig } from "@cosmos.gl/graph";
|
||||
import { cosmosRuntimeConfig } from "./cosmos_config";
|
||||
import {
|
||||
computeLayoutMetrics,
|
||||
type GraphLayoutMetrics,
|
||||
type TripleGraphLink,
|
||||
type TripleGraphModel,
|
||||
type TripleGraphNode,
|
||||
} from "./triple_graph";
|
||||
|
||||
type TripleGraphViewProps = {
|
||||
model: TripleGraphModel;
|
||||
};
|
||||
|
||||
type InspectState =
|
||||
| { kind: "node"; node: TripleGraphNode }
|
||||
| { kind: "link"; link: TripleGraphLink }
|
||||
| null;
|
||||
|
||||
type LayoutDebugState = {
|
||||
phase: "idle" | "running" | "ended";
|
||||
alpha: number | null;
|
||||
progress: number;
|
||||
currentMetrics: GraphLayoutMetrics;
|
||||
lastEvent: string;
|
||||
zoomLevel: number;
|
||||
screenCenter: { x: number; y: number };
|
||||
screenOrigin: { x: number; y: number };
|
||||
screenCentroid: { x: number; y: number };
|
||||
originDelta: { x: number; y: number };
|
||||
centroidDelta: { x: number; y: number };
|
||||
nearSpaceBoundary: boolean;
|
||||
};
|
||||
|
||||
export const TripleGraphView = memo(function TripleGraphView({ model }: TripleGraphViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const modelRef = useRef(model);
|
||||
const debugLogTimeRef = useRef(0);
|
||||
const [hovered, setHovered] = useState<InspectState>(null);
|
||||
const [pinned, setPinned] = useState<InspectState>(null);
|
||||
const [layoutDebug, setLayoutDebug] = useState<LayoutDebugState>({
|
||||
phase: "idle",
|
||||
alpha: null,
|
||||
progress: 0,
|
||||
currentMetrics: model.seedMetrics,
|
||||
lastEvent: "seed",
|
||||
zoomLevel: 0,
|
||||
screenCenter: { x: 0, y: 0 },
|
||||
screenOrigin: { x: 0, y: 0 },
|
||||
screenCentroid: { x: 0, y: 0 },
|
||||
originDelta: { x: 0, y: 0 },
|
||||
centroidDelta: { x: 0, y: 0 },
|
||||
nearSpaceBoundary: false,
|
||||
});
|
||||
|
||||
const activeDetail = useMemo(() => pinned ?? hovered, [pinned, hovered]);
|
||||
|
||||
useEffect(() => {
|
||||
modelRef.current = model;
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
setLayoutDebug({
|
||||
phase: "idle",
|
||||
alpha: null,
|
||||
progress: 0,
|
||||
currentMetrics: model.seedMetrics,
|
||||
lastEvent: "seed",
|
||||
zoomLevel: 0,
|
||||
screenCenter: { x: 0, y: 0 },
|
||||
screenOrigin: { x: 0, y: 0 },
|
||||
screenCentroid: { x: 0, y: 0 },
|
||||
originDelta: { x: 0, y: 0 },
|
||||
centroidDelta: { x: 0, y: 0 },
|
||||
nearSpaceBoundary: false,
|
||||
});
|
||||
if (cosmosRuntimeConfig.debugLayout) {
|
||||
console.debug("[cosmos-layout]", {
|
||||
event: "seed-applied",
|
||||
seedCentroid: {
|
||||
x: Number(model.seedMetrics.centroidX.toFixed(3)),
|
||||
y: Number(model.seedMetrics.centroidY.toFixed(3)),
|
||||
},
|
||||
bounds: {
|
||||
width: Number(model.seedMetrics.width.toFixed(3)),
|
||||
height: Number(model.seedMetrics.height.toFixed(3)),
|
||||
maxRadius: Number(model.seedMetrics.maxRadius.toFixed(3)),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const reheatSimulation = () => {
|
||||
if (!cosmosRuntimeConfig.enableSimulation) return;
|
||||
graphRef.current?.start(0.25);
|
||||
};
|
||||
|
||||
const reportLayout = (event: string, phase: LayoutDebugState["phase"], alpha?: number) => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || !cosmosRuntimeConfig.debugLayout) return;
|
||||
const currentMetrics = computeLayoutMetrics(graph.getPointPositions());
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const screenCenter = {
|
||||
x: containerRect.width / 2,
|
||||
y: containerRect.height / 2,
|
||||
};
|
||||
const screenOriginTuple = graph.spaceToScreenPosition([0, 0]);
|
||||
const screenCentroidTuple = graph.spaceToScreenPosition([
|
||||
currentMetrics.centroidX,
|
||||
currentMetrics.centroidY,
|
||||
]);
|
||||
const screenOrigin = { x: screenOriginTuple[0], y: screenOriginTuple[1] };
|
||||
const screenCentroid = { x: screenCentroidTuple[0], y: screenCentroidTuple[1] };
|
||||
const originDelta = {
|
||||
x: screenOrigin.x - screenCenter.x,
|
||||
y: screenOrigin.y - screenCenter.y,
|
||||
};
|
||||
const centroidDelta = {
|
||||
x: screenCentroid.x - screenCenter.x,
|
||||
y: screenCentroid.y - screenCenter.y,
|
||||
};
|
||||
const boundaryMargin = cosmosRuntimeConfig.spaceSize * 0.02;
|
||||
const nearSpaceBoundary =
|
||||
currentMetrics.minX <= boundaryMargin ||
|
||||
currentMetrics.maxX >= cosmosRuntimeConfig.spaceSize - boundaryMargin ||
|
||||
currentMetrics.minY <= boundaryMargin ||
|
||||
currentMetrics.maxY >= cosmosRuntimeConfig.spaceSize - boundaryMargin;
|
||||
const now = performance.now();
|
||||
const shouldPublish = event !== "tick" || now - debugLogTimeRef.current >= 250;
|
||||
const next: LayoutDebugState = {
|
||||
phase,
|
||||
alpha: typeof alpha === "number" ? alpha : null,
|
||||
progress: graph.progress,
|
||||
currentMetrics,
|
||||
lastEvent: event,
|
||||
zoomLevel: graph.getZoomLevel(),
|
||||
screenCenter,
|
||||
screenOrigin,
|
||||
screenCentroid,
|
||||
originDelta,
|
||||
centroidDelta,
|
||||
nearSpaceBoundary,
|
||||
};
|
||||
if (!shouldPublish) return;
|
||||
debugLogTimeRef.current = now;
|
||||
setLayoutDebug(next);
|
||||
console.debug("[cosmos-layout]", {
|
||||
event,
|
||||
phase,
|
||||
alpha: next.alpha,
|
||||
progress: Number(next.progress.toFixed(4)),
|
||||
seedCentroid: {
|
||||
x: Number(modelRef.current.seedMetrics.centroidX.toFixed(3)),
|
||||
y: Number(modelRef.current.seedMetrics.centroidY.toFixed(3)),
|
||||
},
|
||||
currentCentroid: {
|
||||
x: Number(currentMetrics.centroidX.toFixed(3)),
|
||||
y: Number(currentMetrics.centroidY.toFixed(3)),
|
||||
},
|
||||
screenCenter: {
|
||||
x: Number(screenCenter.x.toFixed(2)),
|
||||
y: Number(screenCenter.y.toFixed(2)),
|
||||
},
|
||||
screenOrigin: {
|
||||
x: Number(screenOrigin.x.toFixed(2)),
|
||||
y: Number(screenOrigin.y.toFixed(2)),
|
||||
},
|
||||
screenCentroid: {
|
||||
x: Number(screenCentroid.x.toFixed(2)),
|
||||
y: Number(screenCentroid.y.toFixed(2)),
|
||||
},
|
||||
originDelta: {
|
||||
x: Number(originDelta.x.toFixed(2)),
|
||||
y: Number(originDelta.y.toFixed(2)),
|
||||
},
|
||||
centroidDelta: {
|
||||
x: Number(centroidDelta.x.toFixed(2)),
|
||||
y: Number(centroidDelta.y.toFixed(2)),
|
||||
},
|
||||
zoomLevel: Number(next.zoomLevel.toFixed(4)),
|
||||
nearSpaceBoundary,
|
||||
bounds: {
|
||||
width: Number(currentMetrics.width.toFixed(3)),
|
||||
height: Number(currentMetrics.height.toFixed(3)),
|
||||
maxRadius: Number(currentMetrics.maxRadius.toFixed(3)),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const config: GraphConfig = {
|
||||
backgroundColor: "#05070a",
|
||||
spaceSize: cosmosRuntimeConfig.spaceSize,
|
||||
enableSimulation: cosmosRuntimeConfig.enableSimulation,
|
||||
enableDrag: true,
|
||||
enableZoom: true,
|
||||
fitViewOnInit: false,
|
||||
fitViewPadding: cosmosRuntimeConfig.fitViewPadding,
|
||||
rescalePositions: false,
|
||||
curvedLinks: cosmosRuntimeConfig.curvedLinks,
|
||||
simulationDecay: cosmosRuntimeConfig.simulationDecay,
|
||||
simulationGravity: cosmosRuntimeConfig.simulationGravity,
|
||||
simulationCenter: cosmosRuntimeConfig.simulationCenter,
|
||||
simulationRepulsion: cosmosRuntimeConfig.simulationRepulsion,
|
||||
simulationLinkSpring: cosmosRuntimeConfig.simulationLinkSpring,
|
||||
simulationLinkDistance: cosmosRuntimeConfig.simulationLinkDistance,
|
||||
simulationFriction: cosmosRuntimeConfig.simulationFriction,
|
||||
renderHoveredPointRing: true,
|
||||
hoveredPointRingColor: "#35d6ff",
|
||||
hoveredPointCursor: "pointer",
|
||||
hoveredLinkCursor: "pointer",
|
||||
hoveredLinkColor: "#ffd166",
|
||||
hoveredLinkWidthIncrease: 2.5,
|
||||
onSimulationStart: () => {
|
||||
reportLayout("simulation-start", "running", 1);
|
||||
},
|
||||
onSimulationTick: (alpha) => {
|
||||
reportLayout("tick", "running", alpha);
|
||||
},
|
||||
onSimulationEnd: () => {
|
||||
reportLayout("simulation-end", "ended", 0);
|
||||
},
|
||||
onPointMouseOver: (index) => {
|
||||
const node = modelRef.current.nodes[index];
|
||||
if (!node) return;
|
||||
setHovered({ kind: "node", node });
|
||||
},
|
||||
onPointMouseOut: () => {
|
||||
setHovered((prev) => (prev?.kind === "node" ? null : prev));
|
||||
},
|
||||
onLinkMouseOver: (linkIndex) => {
|
||||
const link = modelRef.current.linksMeta[linkIndex];
|
||||
if (!link) return;
|
||||
setHovered({ kind: "link", link });
|
||||
},
|
||||
onLinkMouseOut: () => {
|
||||
setHovered((prev) => (prev?.kind === "link" ? null : prev));
|
||||
},
|
||||
onPointClick: (index) => {
|
||||
const node = modelRef.current.nodes[index];
|
||||
if (!node) return;
|
||||
setPinned({ kind: "node", node });
|
||||
},
|
||||
onLinkClick: (linkIndex) => {
|
||||
const link = modelRef.current.linksMeta[linkIndex];
|
||||
if (!link) return;
|
||||
setPinned({ kind: "link", link });
|
||||
},
|
||||
onClick: (index) => {
|
||||
if (typeof index === "number") return;
|
||||
setPinned(null);
|
||||
},
|
||||
onDragStart: () => {
|
||||
reportLayout("drag-start", "running");
|
||||
reheatSimulation();
|
||||
},
|
||||
onDragEnd: () => {
|
||||
reportLayout("drag-end", "running");
|
||||
reheatSimulation();
|
||||
},
|
||||
};
|
||||
|
||||
const graph = new Graph(container, config);
|
||||
graphRef.current = graph;
|
||||
if (cosmosRuntimeConfig.debugLayout) {
|
||||
console.debug("[cosmos-layout]", {
|
||||
event: "graph-created",
|
||||
seedCentroid: {
|
||||
x: Number(modelRef.current.seedMetrics.centroidX.toFixed(3)),
|
||||
y: Number(modelRef.current.seedMetrics.centroidY.toFixed(3)),
|
||||
},
|
||||
seedRadius: Number(modelRef.current.seedMetrics.maxRadius.toFixed(3)),
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
setHovered(null);
|
||||
setPinned(null);
|
||||
graphRef.current = null;
|
||||
graph.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return;
|
||||
setHovered(null);
|
||||
setPinned(null);
|
||||
applyGraphModel(graph, model);
|
||||
if (cosmosRuntimeConfig.debugLayout) {
|
||||
requestAnimationFrame(() => {
|
||||
const positionedGraph = graphRef.current;
|
||||
if (!positionedGraph) return;
|
||||
const currentMetrics = computeLayoutMetrics(positionedGraph.getPointPositions());
|
||||
const origin = positionedGraph.spaceToScreenPosition([0, 0]);
|
||||
const centroid = positionedGraph.spaceToScreenPosition([
|
||||
currentMetrics.centroidX,
|
||||
currentMetrics.centroidY,
|
||||
]);
|
||||
console.debug("[cosmos-layout]", {
|
||||
event: "after-fit-requested",
|
||||
screenOrigin: { x: Number(origin[0].toFixed(2)), y: Number(origin[1].toFixed(2)) },
|
||||
screenCentroid: { x: Number(centroid[0].toFixed(2)), y: Number(centroid[1].toFixed(2)) },
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return;
|
||||
graph.setConfig({
|
||||
focusedPointIndex: activeDetail?.kind === "node" ? activeDetail.node.index : undefined,
|
||||
});
|
||||
}, [activeDetail]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", flex: 1, minHeight: 0, background: "#05070a" }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}
|
||||
/>
|
||||
|
||||
{cosmosRuntimeConfig.debugLayout && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: 12,
|
||||
maxWidth: "min(340px, calc(100% - 24px))",
|
||||
background: "rgba(4, 7, 11, 0.94)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "10px",
|
||||
padding: "10px 12px",
|
||||
color: "#d7e2ea",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1.5,
|
||||
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#88a9b9", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||
Layout Debug
|
||||
</div>
|
||||
<div style={{ marginTop: "6px" }}>
|
||||
phase: {layoutDebug.phase} · event: {layoutDebug.lastEvent}
|
||||
</div>
|
||||
<div>
|
||||
alpha: {formatMaybeNumber(layoutDebug.alpha)} · progress: {formatNumber(layoutDebug.progress)}
|
||||
</div>
|
||||
<div>zoom: {formatNumber(layoutDebug.zoomLevel)}</div>
|
||||
<div style={{ marginTop: "8px", color: "#9ac7d8" }}>seed centroid</div>
|
||||
<div>
|
||||
({formatNumber(model.seedMetrics.centroidX)}, {formatNumber(model.seedMetrics.centroidY)})
|
||||
</div>
|
||||
<div>
|
||||
bounds: {formatNumber(model.seedMetrics.width)} × {formatNumber(model.seedMetrics.height)} · r={formatNumber(model.seedMetrics.maxRadius)}
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", color: "#f0c674" }}>current centroid</div>
|
||||
<div>
|
||||
({formatNumber(layoutDebug.currentMetrics.centroidX)}, {formatNumber(layoutDebug.currentMetrics.centroidY)})
|
||||
</div>
|
||||
<div>
|
||||
bounds: {formatNumber(layoutDebug.currentMetrics.width)} × {formatNumber(layoutDebug.currentMetrics.height)} · r={formatNumber(layoutDebug.currentMetrics.maxRadius)}
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", color: "#9ac7d8" }}>screen center</div>
|
||||
<div>
|
||||
({formatNumber(layoutDebug.screenCenter.x)}, {formatNumber(layoutDebug.screenCenter.y)})
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", color: "#f0c674" }}>screen origin</div>
|
||||
<div>
|
||||
({formatNumber(layoutDebug.screenOrigin.x)}, {formatNumber(layoutDebug.screenOrigin.y)}) d=({formatNumber(layoutDebug.originDelta.x)}, {formatNumber(layoutDebug.originDelta.y)})
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", color: "#f0c674" }}>screen centroid</div>
|
||||
<div>
|
||||
({formatNumber(layoutDebug.screenCentroid.x)}, {formatNumber(layoutDebug.screenCentroid.y)}) d=({formatNumber(layoutDebug.centroidDelta.x)}, {formatNumber(layoutDebug.centroidDelta.y)})
|
||||
</div>
|
||||
<div style={{ marginTop: "8px", color: layoutDebug.nearSpaceBoundary ? "#ff8b8b" : "#8fd2a8" }}>
|
||||
near space boundary: {layoutDebug.nearSpaceBoundary ? "yes" : "no"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
width: "min(420px, calc(100% - 24px))",
|
||||
maxHeight: "calc(100% - 24px)",
|
||||
overflowY: "auto",
|
||||
background: "rgba(4, 7, 11, 0.94)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "10px",
|
||||
padding: "12px 14px",
|
||||
color: "#d7e2ea",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
lineHeight: 1.5,
|
||||
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
|
||||
wordBreak: "break-all",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#88a9b9", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||
{pinned ? "Pinned details" : activeDetail ? "Hovered details" : "Inspector"}
|
||||
</div>
|
||||
|
||||
{!activeDetail && (
|
||||
<div style={{ marginTop: "8px", color: "#a7b7c2" }}>
|
||||
Hover a node or edge to inspect it. Click a node or edge to pin its details.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeDetail?.kind === "node" && (
|
||||
<>
|
||||
<div style={{ marginTop: "8px", color: activeDetail.node.isSelectedSource ? "#35d6ff" : "#9ac7d8", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||
Node
|
||||
</div>
|
||||
<div style={{ marginTop: "4px" }}>{activeDetail.node.text}</div>
|
||||
{typeof activeDetail.node.backendId === "number" && (
|
||||
<div style={{ marginTop: "6px", color: "#88a9b9" }}>
|
||||
backend id: {activeDetail.node.backendId}
|
||||
</div>
|
||||
)}
|
||||
{activeDetail.node.isSelectedSource && (
|
||||
<div style={{ marginTop: "4px", color: "#35d6ff" }}>
|
||||
selected source node
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeDetail?.kind === "link" && (
|
||||
<>
|
||||
<div style={{ marginTop: "8px", color: "#f0c674", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||
Edge
|
||||
</div>
|
||||
<div style={{ marginTop: "4px", color: "#f0c674" }}>{activeDetail.link.predicateText}</div>
|
||||
<div style={{ marginTop: "8px", color: "#88a9b9" }}>from</div>
|
||||
<div>{activeDetail.link.sourceText}</div>
|
||||
<div style={{ marginTop: "8px", color: "#88a9b9" }}>to</div>
|
||||
<div>{activeDetail.link.targetText}</div>
|
||||
{typeof activeDetail.link.predicateId === "number" && (
|
||||
<div style={{ marginTop: "6px", color: "#88a9b9" }}>
|
||||
predicate id: {activeDetail.link.predicateId}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function applyGraphModel(graph: Graph, model: TripleGraphModel): void {
|
||||
graph.setPointPositions(model.pointPositions);
|
||||
graph.setLinks(model.links);
|
||||
graph.setPointColors(model.pointColors);
|
||||
graph.setPointSizes(model.pointSizes);
|
||||
graph.setLinkColors(model.linkColors);
|
||||
graph.setLinkWidths(model.linkWidths);
|
||||
graph.render(0);
|
||||
requestAnimationFrame(() => {
|
||||
graph.fitViewByPointPositions(Array.from(model.pointPositions), 0, cosmosRuntimeConfig.fitViewPadding);
|
||||
if (cosmosRuntimeConfig.enableSimulation) {
|
||||
graph.start(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatMaybeNumber(value: number | null): string {
|
||||
return value === null ? "-" : value.toFixed(3);
|
||||
}
|
||||
28
frontend/src/cosmos_config.ts
Normal file
28
frontend/src/cosmos_config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export const cosmosRuntimeConfig = {
|
||||
enableSimulation: parseBoolean(import.meta.env.VITE_COSMOS_ENABLE_SIMULATION, true),
|
||||
debugLayout: parseBoolean(import.meta.env.VITE_COSMOS_DEBUG_LAYOUT, false),
|
||||
spaceSize: parseNumber(import.meta.env.VITE_COSMOS_SPACE_SIZE, 4096),
|
||||
curvedLinks: parseBoolean(import.meta.env.VITE_COSMOS_CURVED_LINKS, true),
|
||||
fitViewPadding: parseNumber(import.meta.env.VITE_COSMOS_FIT_VIEW_PADDING, 0.12),
|
||||
simulationDecay: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_DECAY, 5000),
|
||||
simulationGravity: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_GRAVITY, 0),
|
||||
simulationCenter: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_CENTER, 0.05),
|
||||
simulationRepulsion: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_REPULSION, 0.5),
|
||||
simulationLinkSpring: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_SPRING, 1),
|
||||
simulationLinkDistance: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_DISTANCE, 10),
|
||||
simulationFriction: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_FRICTION, 0.1),
|
||||
} as const;
|
||||
@@ -76,6 +76,9 @@ export class Renderer {
|
||||
private selectedProgram: WebGLProgram;
|
||||
private neighborProgram: WebGLProgram;
|
||||
private vao: WebGLVertexArrayObject;
|
||||
private nodeVbo: WebGLBuffer;
|
||||
private lineVao: WebGLVertexArrayObject;
|
||||
private lineVbo: WebGLBuffer;
|
||||
|
||||
// Data
|
||||
private leaves: Leaf[] = [];
|
||||
@@ -88,6 +91,8 @@ export class Renderer {
|
||||
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
||||
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
||||
private maxPtSize = 256;
|
||||
private useRawLineSegments = false;
|
||||
private rawLineVertexCount = 0;
|
||||
|
||||
// Multi-draw extension
|
||||
private multiDrawExt: any = null;
|
||||
@@ -163,15 +168,23 @@ export class Renderer {
|
||||
|
||||
// Create VAO + VBO (empty for now)
|
||||
this.vao = gl.createVertexArray()!;
|
||||
this.nodeVbo = gl.createBuffer()!;
|
||||
gl.bindVertexArray(this.vao);
|
||||
const vbo = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
|
||||
|
||||
// We forced a_pos to location 0 in compileProgram
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
this.lineVao = gl.createVertexArray()!;
|
||||
this.lineVbo = gl.createBuffer()!;
|
||||
gl.bindVertexArray(this.lineVao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
this.linesIbo = gl.createBuffer()!;
|
||||
this.selectionIbo = gl.createBuffer()!;
|
||||
this.neighborIbo = gl.createBuffer()!;
|
||||
@@ -192,7 +205,8 @@ export class Renderer {
|
||||
xs: Float32Array,
|
||||
ys: Float32Array,
|
||||
vertexIds: Uint32Array,
|
||||
edges: Uint32Array
|
||||
edges: Uint32Array,
|
||||
routeLineVertices: Float32Array | null = null
|
||||
): number {
|
||||
const t0 = performance.now();
|
||||
const gl = this.gl;
|
||||
@@ -213,6 +227,7 @@ export class Renderer {
|
||||
|
||||
// Upload sorted particles to GPU as STATIC VBO (never changes)
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
@@ -236,6 +251,19 @@ export class Renderer {
|
||||
}
|
||||
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
|
||||
|
||||
this.useRawLineSegments = routeLineVertices !== null && routeLineVertices.length > 0;
|
||||
this.rawLineVertexCount = this.useRawLineSegments && routeLineVertices ? routeLineVertices.length / 2 : 0;
|
||||
if (this.useRawLineSegments && routeLineVertices) {
|
||||
this.edgeCount = edgeCount;
|
||||
this.leafEdgeStarts = new Uint32Array(0);
|
||||
this.leafEdgeCounts = new Uint32Array(0);
|
||||
gl.bindVertexArray(this.lineVao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, routeLineVertices, gl.STATIC_DRAW);
|
||||
gl.bindVertexArray(null);
|
||||
return performance.now() - t0;
|
||||
}
|
||||
|
||||
// Remap edges from vertex IDs to sorted indices
|
||||
const lineIndices = new Uint32Array(edgeCount * 2);
|
||||
let validEdges = 0;
|
||||
@@ -572,24 +600,30 @@ export class Renderer {
|
||||
}
|
||||
|
||||
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
|
||||
if (totalVisibleParticles < 20000 && visibleCount > 0) {
|
||||
if (totalVisibleParticles < 20000) {
|
||||
gl.useProgram(this.lineProgram);
|
||||
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
|
||||
gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||
if (this.useRawLineSegments) {
|
||||
gl.bindVertexArray(this.lineVao);
|
||||
gl.drawArrays(gl.LINES, 0, this.rawLineVertexCount);
|
||||
gl.bindVertexArray(this.vao);
|
||||
} else if (visibleCount > 0) {
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const leafIdx = this.visibleLeafIndices[i];
|
||||
const edgeCount = this.leafEdgeCounts[leafIdx];
|
||||
if (edgeCount === 0) continue;
|
||||
// Each edge is 2 indices (1 line segment)
|
||||
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
||||
const edgeStart = this.leafEdgeStarts[leafIdx];
|
||||
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const leafIdx = this.visibleLeafIndices[i];
|
||||
const edgeCount = this.leafEdgeCounts[leafIdx];
|
||||
if (edgeCount === 0) continue;
|
||||
// Each edge is 2 indices (1 line segment)
|
||||
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
||||
const edgeStart = this.leafEdgeStarts[leafIdx];
|
||||
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||
}
|
||||
|
||||
// 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top
|
||||
|
||||
@@ -1,4 +1,53 @@
|
||||
import type { GraphMeta, SelectionQueryMeta } from "./types";
|
||||
import type {
|
||||
GraphMeta,
|
||||
SelectionQueryMeta,
|
||||
SelectionQueryResult,
|
||||
SelectionTriple,
|
||||
SelectionTripleResult,
|
||||
SelectionTripleTerm,
|
||||
} from "./types";
|
||||
|
||||
function numberArray(value: unknown): number[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: number[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "number") out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tripleTerm(value: unknown): SelectionTripleTerm | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.type !== "string" || typeof record.value !== "string") return null;
|
||||
return {
|
||||
type: record.type,
|
||||
value: record.value,
|
||||
lang: typeof record.lang === "string" ? record.lang : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function tripleArray(value: unknown): SelectionTriple[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: SelectionTriple[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
const s = tripleTerm(record.s);
|
||||
const p = tripleTerm(record.p);
|
||||
const o = tripleTerm(record.o);
|
||||
if (!s || !p || !o) continue;
|
||||
out.push({
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
subject_id: typeof record.subject_id === "number" ? record.subject_id : undefined,
|
||||
predicate_id: typeof record.predicate_id === "number" ? record.predicate_id : undefined,
|
||||
object_id: typeof record.object_id === "number" ? record.object_id : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchSelectionQueries(signal?: AbortSignal): Promise<SelectionQueryMeta[]> {
|
||||
const res = await fetch("/api/selection_queries", { signal });
|
||||
@@ -12,7 +61,7 @@ export async function runSelectionQuery(
|
||||
selectedIds: number[],
|
||||
graphMeta: GraphMeta | null,
|
||||
signal: AbortSignal
|
||||
): Promise<number[]> {
|
||||
): Promise<SelectionQueryResult> {
|
||||
const body = {
|
||||
query_id: queryId,
|
||||
selected_ids: selectedIds,
|
||||
@@ -29,9 +78,40 @@ export async function runSelectionQuery(
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /api/selection_query failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const ids: unknown = data?.neighbor_ids;
|
||||
if (!Array.isArray(ids)) return [];
|
||||
const out: number[] = [];
|
||||
for (const id of ids) if (typeof id === "number") out.push(id);
|
||||
return out;
|
||||
|
||||
return {
|
||||
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
|
||||
selectedIds: numberArray(data?.selected_ids),
|
||||
neighborIds: numberArray(data?.neighbor_ids),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSelectionTripleQuery(
|
||||
queryId: string,
|
||||
selectedIds: number[],
|
||||
graphMeta: GraphMeta | null,
|
||||
signal: AbortSignal
|
||||
): Promise<SelectionTripleResult> {
|
||||
const body = {
|
||||
query_id: queryId,
|
||||
selected_ids: selectedIds,
|
||||
node_limit: typeof graphMeta?.node_limit === "number" ? graphMeta.node_limit : undefined,
|
||||
edge_limit: typeof graphMeta?.edge_limit === "number" ? graphMeta.edge_limit : undefined,
|
||||
graph_query_id: typeof graphMeta?.graph_query_id === "string" ? graphMeta.graph_query_id : undefined,
|
||||
};
|
||||
|
||||
const res = await fetch("/api/selection_triples", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /api/selection_triples failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
return {
|
||||
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
|
||||
selectedIds: numberArray(data?.selected_ids),
|
||||
triples: tripleArray(data?.triples),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export { fetchSelectionQueries, runSelectionQuery } from "./api";
|
||||
export type { GraphMeta, SelectionQueryMeta } from "./types";
|
||||
|
||||
export { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./api";
|
||||
export type {
|
||||
GraphMeta,
|
||||
GraphRoutePoint,
|
||||
GraphRouteSegment,
|
||||
SelectionQueryMeta,
|
||||
SelectionTriple,
|
||||
SelectionTripleResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -8,9 +8,49 @@ export type GraphMeta = {
|
||||
edge_limit?: number;
|
||||
nodes?: number;
|
||||
edges?: number;
|
||||
layout_engine?: string;
|
||||
layout_root_iri?: string | null;
|
||||
};
|
||||
|
||||
export type GraphRoutePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type GraphRouteSegment = {
|
||||
edge_index: number;
|
||||
kind: string;
|
||||
points: GraphRoutePoint[];
|
||||
};
|
||||
|
||||
export type SelectionQueryMeta = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SelectionQueryResult = {
|
||||
queryId: string;
|
||||
selectedIds: number[];
|
||||
neighborIds: number[];
|
||||
};
|
||||
|
||||
export type SelectionTripleTerm = {
|
||||
type: string;
|
||||
value: string;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
export type SelectionTriple = {
|
||||
s: SelectionTripleTerm;
|
||||
p: SelectionTripleTerm;
|
||||
o: SelectionTripleTerm;
|
||||
subject_id?: number;
|
||||
predicate_id?: number;
|
||||
object_id?: number;
|
||||
};
|
||||
|
||||
export type SelectionTripleResult = {
|
||||
queryId: string;
|
||||
selectedIds: number[];
|
||||
triples: SelectionTriple[];
|
||||
};
|
||||
|
||||
363
frontend/src/triple_graph.ts
Normal file
363
frontend/src/triple_graph.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { cosmosRuntimeConfig } from "./cosmos_config";
|
||||
import type { SelectionTriple } from "./selection_queries";
|
||||
|
||||
export type TripleGraphTerm = SelectionTriple["s"];
|
||||
|
||||
export type TripleGraphNode = {
|
||||
key: string;
|
||||
index: number;
|
||||
term: TripleGraphTerm;
|
||||
text: string;
|
||||
backendId?: number;
|
||||
isSelectedSource: boolean;
|
||||
};
|
||||
|
||||
export type TripleGraphLink = {
|
||||
index: number;
|
||||
sourceIndex: number;
|
||||
targetIndex: number;
|
||||
sourceText: string;
|
||||
targetText: string;
|
||||
predicate: SelectionTriple["p"];
|
||||
predicateText: string;
|
||||
predicateId?: number;
|
||||
triple: SelectionTriple;
|
||||
};
|
||||
|
||||
export type TripleGraphModel = {
|
||||
nodes: TripleGraphNode[];
|
||||
linksMeta: TripleGraphLink[];
|
||||
pointPositions: Float32Array;
|
||||
seedMetrics: GraphLayoutMetrics;
|
||||
pointColors: Float32Array;
|
||||
pointSizes: Float32Array;
|
||||
links: Float32Array;
|
||||
linkColors: Float32Array;
|
||||
linkWidths: Float32Array;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
};
|
||||
|
||||
export type GraphLayoutMetrics = {
|
||||
centroidX: number;
|
||||
centroidY: number;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
maxRadius: number;
|
||||
};
|
||||
|
||||
type MutableNode = {
|
||||
term: TripleGraphTerm;
|
||||
text: string;
|
||||
backendId?: number;
|
||||
isSelectedSource: boolean;
|
||||
};
|
||||
|
||||
export function buildTripleGraphModel(triples: SelectionTriple[], selectedIds: number[]): TripleGraphModel {
|
||||
const selectedSet = new Set<number>(selectedIds);
|
||||
const nodeMap = new Map<string, MutableNode>();
|
||||
|
||||
for (const triple of triples) {
|
||||
addNode(nodeMap, triple.s, triple.subject_id, selectedSet);
|
||||
addNode(nodeMap, triple.o, triple.object_id, selectedSet);
|
||||
}
|
||||
|
||||
const nodes = Array.from(nodeMap.entries())
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||
.map(([key, node], index) => ({
|
||||
key,
|
||||
index,
|
||||
term: node.term,
|
||||
text: node.text,
|
||||
backendId: node.backendId,
|
||||
isSelectedSource: node.isSelectedSource,
|
||||
}));
|
||||
|
||||
const nodeIndexByKey = new Map<string, number>();
|
||||
for (const node of nodes) {
|
||||
nodeIndexByKey.set(node.key, node.index);
|
||||
}
|
||||
|
||||
const linksMeta: TripleGraphLink[] = [];
|
||||
for (const triple of triples) {
|
||||
const sourceIndex = nodeIndexByKey.get(termKey(triple.s));
|
||||
const targetIndex = nodeIndexByKey.get(termKey(triple.o));
|
||||
if (sourceIndex === undefined || targetIndex === undefined) continue;
|
||||
linksMeta.push({
|
||||
index: linksMeta.length,
|
||||
sourceIndex,
|
||||
targetIndex,
|
||||
sourceText: formatTermText(triple.s),
|
||||
targetText: formatTermText(triple.o),
|
||||
predicate: triple.p,
|
||||
predicateText: formatTermText(triple.p),
|
||||
predicateId: triple.predicate_id,
|
||||
triple,
|
||||
});
|
||||
}
|
||||
|
||||
const pointPositions = buildPointPositions(nodes);
|
||||
const seedMetrics = computeLayoutMetrics(pointPositions);
|
||||
const pointColors = buildPointColors(nodes);
|
||||
const pointSizes = buildPointSizes(nodes);
|
||||
const links = buildLinks(linksMeta);
|
||||
const linkColors = buildLinkColors(linksMeta);
|
||||
const linkWidths = buildLinkWidths(linksMeta);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
linksMeta,
|
||||
pointPositions,
|
||||
seedMetrics,
|
||||
pointColors,
|
||||
pointSizes,
|
||||
links,
|
||||
linkColors,
|
||||
linkWidths,
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: linksMeta.length,
|
||||
};
|
||||
}
|
||||
|
||||
function addNode(
|
||||
nodeMap: Map<string, MutableNode>,
|
||||
term: TripleGraphTerm,
|
||||
backendId: number | undefined,
|
||||
selectedSet: Set<number>
|
||||
): void {
|
||||
const key = termKey(term);
|
||||
const existing = nodeMap.get(key);
|
||||
const isSelectedSource = typeof backendId === "number" && selectedSet.has(backendId);
|
||||
if (existing) {
|
||||
if (existing.backendId === undefined && typeof backendId === "number") {
|
||||
existing.backendId = backendId;
|
||||
}
|
||||
if (isSelectedSource) existing.isSelectedSource = true;
|
||||
return;
|
||||
}
|
||||
nodeMap.set(key, {
|
||||
term,
|
||||
text: formatTermText(term),
|
||||
backendId,
|
||||
isSelectedSource,
|
||||
});
|
||||
}
|
||||
|
||||
function termKey(term: TripleGraphTerm): string {
|
||||
return `${term.type}\x00${term.value}`;
|
||||
}
|
||||
|
||||
function formatTermText(term: TripleGraphTerm): string {
|
||||
if (term.type === "literal") {
|
||||
if (term.lang) return `"${term.value}"@${term.lang}`;
|
||||
return `"${term.value}"`;
|
||||
}
|
||||
return term.value;
|
||||
}
|
||||
|
||||
function buildPointPositions(nodes: TripleGraphNode[]): Float32Array {
|
||||
const out = new Float32Array(nodes.length * 2);
|
||||
const simulationSpaceCenter = cosmosRuntimeConfig.spaceSize / 2;
|
||||
if (nodes.length === 0) return out;
|
||||
if (nodes.length === 1) {
|
||||
out[0] = simulationSpaceCenter;
|
||||
out[1] = simulationSpaceCenter;
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const primaryHash = hashString(node.key);
|
||||
const secondaryHash = hashString(`${node.key}\x01`);
|
||||
const angle = ((primaryHash % 3600) / 3600) * Math.PI * 2;
|
||||
const radius = 80 + (((primaryHash >>> 12) % 1000) / 1000) * 70;
|
||||
const jitterX = ((((secondaryHash >>> 4) % 200) / 200) - 0.5) * 18;
|
||||
const jitterY = ((((secondaryHash >>> 12) % 200) / 200) - 0.5) * 18;
|
||||
out[node.index * 2] = Math.cos(angle) * radius + jitterX;
|
||||
out[node.index * 2 + 1] = Math.sin(angle) * radius + jitterY;
|
||||
}
|
||||
|
||||
recenterPointPositions(out);
|
||||
offsetPointPositionsToSimulationCenter(out, simulationSpaceCenter);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeLayoutMetrics(pointPositions: ArrayLike<number>): GraphLayoutMetrics {
|
||||
const pairCount = Math.floor(pointPositions.length / 2);
|
||||
if (pairCount === 0) {
|
||||
return {
|
||||
centroidX: 0,
|
||||
centroidY: 0,
|
||||
minX: 0,
|
||||
maxX: 0,
|
||||
minY: 0,
|
||||
maxY: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
maxRadius: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (let i = 0; i < pairCount; i++) {
|
||||
const x = pointPositions[i * 2];
|
||||
const y = pointPositions[i * 2 + 1];
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
|
||||
const centroidX = sumX / pairCount;
|
||||
const centroidY = sumY / pairCount;
|
||||
let maxRadius = 0;
|
||||
for (let i = 0; i < pairCount; i++) {
|
||||
const dx = pointPositions[i * 2] - centroidX;
|
||||
const dy = pointPositions[i * 2 + 1] - centroidY;
|
||||
const radius = Math.hypot(dx, dy);
|
||||
if (radius > maxRadius) maxRadius = radius;
|
||||
}
|
||||
|
||||
return {
|
||||
centroidX,
|
||||
centroidY,
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
maxRadius,
|
||||
};
|
||||
}
|
||||
|
||||
function recenterPointPositions(pointPositions: Float32Array): void {
|
||||
const metrics = computeLayoutMetrics(pointPositions);
|
||||
if (metrics.centroidX === 0 && metrics.centroidY === 0) return;
|
||||
const pairCount = Math.floor(pointPositions.length / 2);
|
||||
for (let i = 0; i < pairCount; i++) {
|
||||
pointPositions[i * 2] -= metrics.centroidX;
|
||||
pointPositions[i * 2 + 1] -= metrics.centroidY;
|
||||
}
|
||||
}
|
||||
|
||||
function offsetPointPositionsToSimulationCenter(pointPositions: Float32Array, center: number): void {
|
||||
if (center === 0) return;
|
||||
const pairCount = Math.floor(pointPositions.length / 2);
|
||||
for (let i = 0; i < pairCount; i++) {
|
||||
pointPositions[i * 2] += center;
|
||||
pointPositions[i * 2 + 1] += center;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPointColors(nodes: TripleGraphNode[]): Float32Array {
|
||||
const out = new Float32Array(nodes.length * 4);
|
||||
for (const node of nodes) {
|
||||
const offset = node.index * 4;
|
||||
const color = node.isSelectedSource ? [53, 214, 255, 1] : colorFromHash(node.key, 210, 35, 58, 18, 8);
|
||||
out[offset] = color[0];
|
||||
out[offset + 1] = color[1];
|
||||
out[offset + 2] = color[2];
|
||||
out[offset + 3] = color[3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPointSizes(nodes: TripleGraphNode[]): Float32Array {
|
||||
const out = new Float32Array(nodes.length);
|
||||
for (const node of nodes) {
|
||||
out[node.index] = node.isSelectedSource ? 11 : 7.5;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildLinks(linksMeta: TripleGraphLink[]): Float32Array {
|
||||
const out = new Float32Array(linksMeta.length * 2);
|
||||
for (const link of linksMeta) {
|
||||
const offset = link.index * 2;
|
||||
out[offset] = link.sourceIndex;
|
||||
out[offset + 1] = link.targetIndex;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildLinkColors(linksMeta: TripleGraphLink[]): Float32Array {
|
||||
const out = new Float32Array(linksMeta.length * 4);
|
||||
for (const link of linksMeta) {
|
||||
const offset = link.index * 4;
|
||||
const color = colorFromHash(link.predicateText, 28, 65, 58, 32, 10);
|
||||
out[offset] = color[0];
|
||||
out[offset + 1] = color[1];
|
||||
out[offset + 2] = color[2];
|
||||
out[offset + 3] = color[3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildLinkWidths(linksMeta: TripleGraphLink[]): Float32Array {
|
||||
const out = new Float32Array(linksMeta.length);
|
||||
for (const link of linksMeta) {
|
||||
out[link.index] = 1.8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function colorFromHash(
|
||||
value: string,
|
||||
baseHue: number,
|
||||
hueRange: number,
|
||||
lightness: number,
|
||||
saturation: number,
|
||||
lightnessRange: number
|
||||
): [number, number, number, number] {
|
||||
const hash = hashString(value);
|
||||
const hue = (baseHue + (hash % hueRange) + 360) % 360;
|
||||
const sat = saturation + ((hash >>> 10) % 10);
|
||||
const light = lightness + ((hash >>> 20) % lightnessRange) - lightnessRange / 2;
|
||||
const [r, g, b] = hslToRgb(hue / 360, sat / 100, light / 100);
|
||||
return [r, g, b, 1];
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
if (s === 0) {
|
||||
const value = Math.round(l * 255);
|
||||
return [value, value, value];
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const r = hueToRgb(p, q, h + 1 / 3);
|
||||
const g = hueToRgb(p, q, h);
|
||||
const b = hueToRgb(p, q, h - 1 / 3);
|
||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||
}
|
||||
|
||||
function hueToRgb(p: number, q: number, t: number): number {
|
||||
let value = t;
|
||||
if (value < 0) value += 1;
|
||||
if (value > 1) value -= 1;
|
||||
if (value < 1 / 6) return p + (q - p) * 6 * value;
|
||||
if (value < 1 / 2) return q;
|
||||
if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
21
frontend/src/vite-env.d.ts
vendored
Normal file
21
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BACKEND_URL?: string;
|
||||
readonly VITE_COSMOS_ENABLE_SIMULATION?: string;
|
||||
readonly VITE_COSMOS_DEBUG_LAYOUT?: string;
|
||||
readonly VITE_COSMOS_SPACE_SIZE?: string;
|
||||
readonly VITE_COSMOS_CURVED_LINKS?: string;
|
||||
readonly VITE_COSMOS_FIT_VIEW_PADDING?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_DECAY?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_GRAVITY?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_CENTER?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_REPULSION?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_LINK_SPRING?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_LINK_DISTANCE?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_FRICTION?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user