radial sugiyama positioning integration

This commit is contained in:
Oxy8
2026-03-23 11:13:27 -03:00
parent 6b9115e43b
commit 696844f341
51 changed files with 10089 additions and 364 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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>
);
}

View 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);
}

View 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;

View File

@@ -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

View File

@@ -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),
};
}

View File

@@ -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";

View File

@@ -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[];
};

View 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
View 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;
}