From e56eccb4b0aa7dd7a3f772976a9a8b115048b374 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 09:18:18 -0400 Subject: [PATCH 1/9] feat: replace d3 canvas graph renderer with Sigma.js WebGL renderer Swap the hand-rolled d3-force canvas renderer (~1100 lines of manual hit-testing, arc math and label culling) for sigma.js v3 + graphology: - WebGL rendering scales to tens of thousands of nodes (the canvas renderer struggles past a few hundred); labels get automatic collision/density handling. - ForceAtlas2 layout runs in a web worker, so the UI thread never blocks during layout; it auto-stops after 4s. - Parallel edges between the same node pair fan out as distinct curves (@sigma/edge-curve), preserving the sibling-edge behavior. - Nodes sized by degree (capped); neighbor highlighting on hover; drag-to-pin; double-click to expand/collapse preserved. - Implements the GraphContainer ref API (searchNode/focusNode/ zoomToFit) with the same matching semantics as the d3 renderer. - buildGraph keeps the d3-force contract of resolving edge source/target uids to node objects in place - EdgeProperties and GraphParser.collapseNode depend on that mutation. Covered by 12 unit tests. - sigma is mocked under Jest (jsdom has no WebGL2RenderingContext). - scripts/graph-smoke.mjs: puppeteer smoke test that seeds a local Dgraph, logs in, runs a query through the real UI and asserts the WebGL canvases render and search/zoomToFit work. - Drop the d3 dependency (D3Graph was its only consumer). Verified: 12/12 buildGraph tests, production build, and the browser smoke test against Dgraph v25 with ACL (3 nodes / 3 edges rendered via WebGL, search + zoom-to-fit exercised, zero page errors). Co-Authored-By: Claude Fable 5 --- client/config/jest/sigmaMock.js | 38 + client/package-lock.json | 417 ++---- client/package.json | 34 +- client/scripts/graph-smoke.mjs | 184 +++ client/src/components/D3Graph/D3Graph.scss | 34 - client/src/components/D3Graph/index.js | 1181 ----------------- client/src/components/GraphContainer.js | 4 +- .../src/components/SigmaGraph/SigmaGraph.scss | 9 + .../src/components/SigmaGraph/buildGraph.js | 107 ++ .../components/SigmaGraph/buildGraph.test.js | 190 +++ client/src/components/SigmaGraph/index.js | 269 ++++ 11 files changed, 922 insertions(+), 1545 deletions(-) create mode 100644 client/config/jest/sigmaMock.js create mode 100644 client/scripts/graph-smoke.mjs delete mode 100644 client/src/components/D3Graph/D3Graph.scss delete mode 100644 client/src/components/D3Graph/index.js create mode 100644 client/src/components/SigmaGraph/SigmaGraph.scss create mode 100644 client/src/components/SigmaGraph/buildGraph.js create mode 100644 client/src/components/SigmaGraph/buildGraph.test.js create mode 100644 client/src/components/SigmaGraph/index.js diff --git a/client/config/jest/sigmaMock.js b/client/config/jest/sigmaMock.js new file mode 100644 index 00000000..0d7089f8 --- /dev/null +++ b/client/config/jest/sigmaMock.js @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Stand-in for `sigma`, `sigma/rendering` and `@sigma/edge-curve` under +// Jest: sigma's CJS bundle references WebGL2RenderingContext at module +// load, which doesn't exist in jsdom. +class MockSigma { + on() { + return this + } + refresh() {} + kill() {} + getNodeAttribute() {} + getEdgeAttribute() {} + getCustomBBox() { + return null + } + setCustomBBox() {} + getBBox() { + return { x: [0, 1], y: [0, 1] } + } + viewportToGraph() { + return { x: 0, y: 0 } + } +} + +class MockProgram {} + +module.exports = { + __esModule: true, + default: MockSigma, + Sigma: MockSigma, + EdgeArrowProgram: MockProgram, + EdgeCurveProgram: MockProgram, + EdgeCurvedArrowProgram: MockProgram, +} diff --git a/client/package-lock.json b/client/package-lock.json index d238f7f4..6f734a8b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/node": "^7.20.2", "@fortawesome/fontawesome-free": "^6.2.0", + "@sigma/edge-curve": "^3.1.0", "bootstrap": "4.6.2", "browserslist": "^4.21.4", "classnames": "^2.3.2", @@ -18,8 +19,10 @@ "codemirror-graphql": "^0.15.2", "core-js": "^3.26.0", "crypto-js": "^4.2.0", - "d3": "^5.16.0", "dgraph-js-http": "^21.3.1", + "graphology": "^0.26.0", + "graphology-layout": "^0.6.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphql": "^15.8.0", "immer": "^9.0.16", "jquery": "^3.6.1", @@ -49,6 +52,7 @@ "redux-persist": "^6.0.0", "redux-thunk": "^2.4.2", "screenfull": "^5.2.0", + "sigma": "^3.0.3", "use-interval": "^1.4.0", "uuid": "^3.4.0", "web-vitals": "^3.0.4" @@ -5290,6 +5294,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@sigma/edge-curve": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigma/edge-curve/-/edge-curve-3.1.0.tgz", + "integrity": "sha512-OFWkfAXEsm+X8l1K4K49cC0psB0gQ+gqxKA08HG5piNPdzrDZ5gG9Gza6htZ5AirOVwd/4/uq/gPpD5En+H+3Q==", + "license": "MIT", + "peerDependencies": { + "sigma": ">=3.0.0-beta.10" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.47", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", @@ -8956,319 +8969,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/d3": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz", - "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1", - "d3-axis": "1", - "d3-brush": "1", - "d3-chord": "1", - "d3-collection": "1", - "d3-color": "1", - "d3-contour": "1", - "d3-dispatch": "1", - "d3-drag": "1", - "d3-dsv": "1", - "d3-ease": "1", - "d3-fetch": "1", - "d3-force": "1", - "d3-format": "1", - "d3-geo": "1", - "d3-hierarchy": "1", - "d3-interpolate": "1", - "d3-path": "1", - "d3-polygon": "1", - "d3-quadtree": "1", - "d3-random": "1", - "d3-scale": "2", - "d3-scale-chromatic": "1", - "d3-selection": "1", - "d3-shape": "1", - "d3-time": "1", - "d3-time-format": "2", - "d3-timer": "1", - "d3-transition": "1", - "d3-voronoi": "1", - "d3-zoom": "1" - } - }, - "node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-axis": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", - "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-brush": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.6.tgz", - "integrity": "sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1", - "d3-drag": "1", - "d3-interpolate": "1", - "d3-selection": "1", - "d3-transition": "1" - } - }, - "node_modules/d3-chord": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", - "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1", - "d3-path": "1" - } - }, - "node_modules/d3-collection": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", - "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-color": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", - "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-contour": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", - "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^1.1.1" - } - }, - "node_modules/d3-dispatch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", - "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-drag": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", - "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1", - "d3-selection": "1" - } - }, - "node_modules/d3-dsv": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", - "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", - "license": "BSD-3-Clause", - "dependencies": { - "commander": "2", - "iconv-lite": "0.4", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json", - "csv2tsv": "bin/dsv2dsv", - "dsv2dsv": "bin/dsv2dsv", - "dsv2json": "bin/dsv2json", - "json2csv": "bin/json2dsv", - "json2dsv": "bin/json2dsv", - "json2tsv": "bin/json2dsv", - "tsv2csv": "bin/dsv2dsv", - "tsv2json": "bin/dsv2json" - } - }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/d3-ease": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", - "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-fetch": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz", - "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dsv": "1" - } - }, - "node_modules/d3-force": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", - "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-collection": "1", - "d3-dispatch": "1", - "d3-quadtree": "1", - "d3-timer": "1" - } - }, - "node_modules/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-geo": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", - "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1" - } - }, - "node_modules/d3-hierarchy": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", - "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-interpolate": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", - "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1" - } - }, - "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-polygon": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", - "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-quadtree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", - "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-random": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", - "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-scale": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", - "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^1.2.0", - "d3-collection": "1", - "d3-format": "1", - "d3-interpolate": "1", - "d3-time": "1", - "d3-time-format": "2" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", - "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1", - "d3-interpolate": "1" - } - }, - "node_modules/d3-selection": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", - "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", - "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-time-format": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", - "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-time": "1" - } - }, - "node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-transition": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", - "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-color": "1", - "d3-dispatch": "1", - "d3-ease": "1", - "d3-interpolate": "1", - "d3-selection": "^1.1.0", - "d3-timer": "1" - } - }, - "node_modules/d3-voronoi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", - "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-zoom": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", - "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1", - "d3-drag": "1", - "d3-interpolate": "1", - "d3-selection": "1", - "d3-transition": "1" - } - }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -10394,7 +10094,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -11506,6 +11205,52 @@ "dev": true, "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-layout": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/graphology-layout/-/graphology-layout-0.6.1.tgz", + "integrity": "sha512-m9aMvbd0uDPffUCFPng5ibRkb2pmfNvdKjQWeZrf71RS1aOoat5874+DcyNfMeCT4aQguKC7Lj9eCbqZj/h8Ag==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.3.0", + "pandemonium": "^2.4.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-layout-forceatlas2": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/graphology-layout-forceatlas2/-/graphology-layout-forceatlas2-0.10.1.tgz", + "integrity": "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.1.0" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/graphql": { "version": "15.10.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz", @@ -12308,6 +12053,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" @@ -15602,6 +15348,15 @@ "license": "MIT", "optional": true }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -16055,6 +15810,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -16287,6 +16048,15 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -19463,12 +19233,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -19555,6 +19319,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, "license": "MIT" }, "node_modules/sane": { @@ -20229,6 +19994,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sigma": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz", + "integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", diff --git a/client/package.json b/client/package.json index 87f3d26e..ea081e8a 100644 --- a/client/package.json +++ b/client/package.json @@ -62,10 +62,19 @@ "@babel/preset-react" ] }, - "browserslist": [">2%", "last 3 versions", "Firefox ESR", "not ie < 9"], + "browserslist": [ + ">2%", + "last 3 versions", + "Firefox ESR", + "not ie < 9" + ], "jest": { - "collectCoverageFrom": ["src/**/*.{js,jsx,mjs}"], - "setupFiles": ["/config/polyfills.js"], + "collectCoverageFrom": [ + "src/**/*.{js,jsx,mjs}" + ], + "setupFiles": [ + "/config/polyfills.js" + ], "testEnvironment": "jsdom", "testMatch": [ "/src/**/__tests__/**/*.{js,jsx,mjs}", @@ -77,10 +86,17 @@ "^.+\\.css$": "/config/jest/cssTransform.js", "^(?!.*\\.(js|jsx|mjs|css|json)$)": "/config/jest/fileTransform.js" }, - "transformIgnorePatterns": ["[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"], - "modulePaths": ["./src/"], + "transformIgnorePatterns": [ + "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$" + ], + "modulePaths": [ + "./src/" + ], "moduleNameMapper": { - "^react-native$": "react-native-web" + "^react-native$": "react-native-web", + "^sigma$": "/config/jest/sigmaMock.js", + "^sigma/rendering$": "/config/jest/sigmaMock.js", + "^@sigma/edge-curve$": "/config/jest/sigmaMock.js" }, "moduleFileExtensions": [ "web.js", @@ -175,6 +191,7 @@ "dependencies": { "@babel/node": "^7.20.2", "@fortawesome/fontawesome-free": "^6.2.0", + "@sigma/edge-curve": "^3.1.0", "bootstrap": "4.6.2", "browserslist": "^4.21.4", "classnames": "^2.3.2", @@ -182,8 +199,10 @@ "codemirror-graphql": "^0.15.2", "core-js": "^3.26.0", "crypto-js": "^4.2.0", - "d3": "^5.16.0", "dgraph-js-http": "^21.3.1", + "graphology": "^0.26.0", + "graphology-layout": "^0.6.1", + "graphology-layout-forceatlas2": "^0.10.1", "graphql": "^15.8.0", "immer": "^9.0.16", "jquery": "^3.6.1", @@ -213,6 +232,7 @@ "redux-persist": "^6.0.0", "redux-thunk": "^2.4.2", "screenfull": "^5.2.0", + "sigma": "^3.0.3", "use-interval": "^1.4.0", "uuid": "^3.4.0", "web-vitals": "^3.0.4" diff --git a/client/scripts/graph-smoke.mjs b/client/scripts/graph-smoke.mjs new file mode 100644 index 00000000..c4e7a596 --- /dev/null +++ b/client/scripts/graph-smoke.mjs @@ -0,0 +1,184 @@ +// Smoke test: drive the real Ratel UI (with the new SigmaGraph renderer) +// against the local Dgraph, run a query, and verify the WebGL graph renders. +import puppeteer from 'puppeteer' + +const RATEL = 'http://localhost:3000' +const DGRAPH = 'http://localhost:8080' + +const die = async (msg) => { + console.error('FAIL:', msg) + process.exit(1) +} + +// 0. Login to Dgraph (ACL is enabled on this cluster). +const loginRes = await fetch(`${DGRAPH}/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userid: 'groot', password: 'password' }), +}).then((r) => r.json()) +const accessJwt = loginRes.data && loginRes.data.accessJWT +if (!accessJwt) await die('dgraph login failed: ' + JSON.stringify(loginRes).slice(0, 300)) +console.log('dgraph login ok') + +// 1. Seed a small graph (namespaced predicate to avoid clobbering user data). +const stamp = `smoke${Date.now() % 99991}` +const rdf = ` + _:a <${stamp}_name> "Alice" . + _:b <${stamp}_name> "Bob" . + _:c <${stamp}_name> "Carol" . + _:a <${stamp}_friend> _:b . + _:a <${stamp}_friend> _:c . + _:b <${stamp}_friend> _:c . +` +const mutateRes = await fetch(`${DGRAPH}/mutate?commitNow=true`, { + method: 'POST', + headers: { + 'Content-Type': 'application/rdf', + 'X-Dgraph-AccessToken': accessJwt, + }, + body: `{ set { ${rdf} } }`, +}).then((r) => r.json()) +if (!mutateRes.data || mutateRes.data.code !== 'Success') { + await die('mutation failed: ' + JSON.stringify(mutateRes).slice(0, 300)) +} +console.log('seeded test data') + +const browser = await puppeteer.launch({ + headless: 'new', + executablePath: process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + defaultViewport: { width: 1280, height: 1024 }, +}) +const page = await browser.newPage() + +const pageErrors = [] +const consoleErrors = [] +page.on('pageerror', (err) => pageErrors.push(String(err))) +page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) +}) +page.on('response', async (res) => { + if (/login|admin/.test(res.url())) { + let body = '' + try { + body = (await res.text()).slice(0, 300) + } catch (e) {} + console.log('NET', res.status(), res.url(), body) + } +}) + +const waitGone = async (sel, timeout = 20000) => { + const start = Date.now() + while (await page.$(sel)) { + if (Date.now() - start > timeout) await die('timeout waiting for ' + sel + ' to disappear') + await new Promise((r) => setTimeout(r, 200)) + } +} + +await page.goto(`${RATEL}?addr=${DGRAPH}`, { waitUntil: 'networkidle2' }) +await page.waitForSelector('.editor-panel .CodeMirror-cursors', { timeout: 20000 }) +console.log('app loaded') + +// 2. Login through the UI (ACL cluster). +if (!(await page.$('.modal.server-connection #serverUrlInput'))) { + await page.click('.sidebar-menu a[href="#connection"]') +} +await page.waitForSelector('#useridInput', { timeout: 10000 }) +await page.click('#useridInput', { clickCount: 3 }) +await page.keyboard.type('groot') +await page.click('#passwordInput', { clickCount: 3 }) +await page.keyboard.type('password') +const loginClicked = await page.$$eval( + '.modal.server-connection .modal-body button.btn.btn-primary', + (btns) => { + const b = btns.find((x) => x.textContent === 'Login') + if (b) b.click() + return !!b + }, +) +if (!loginClicked) await die('no Login button found') +const spinner = '.modal.server-connection .modal-body button.btn-primary .fa-spinner.fa-pulse' +await page.waitForSelector(spinner, { timeout: 10000 }).catch(() => {}) +await waitGone(spinner) +console.log('ui login done') + +// Back to console tab. +await page.click(".sidebar-menu a[href='#']") +await page.waitForSelector('.editor-panel .CodeMirror-cursors', { timeout: 10000 }) +await page.click('.editor-panel .CodeMirror') + +const query = `{ q(func: has(${stamp}_name)) { uid ${stamp}_name ${stamp}_friend { uid ${stamp}_name } } }` +await page.evaluate((q) => { + document.querySelector('.editor-panel .CodeMirror').CodeMirror.setValue(q) +}, query) +await new Promise((r) => setTimeout(r, 1000)) +await page.$$eval('.editor-panel button', (btns) => { + const run = btns.find((b) => b.textContent.trim() === 'Run') + if (run) run.click() +}) +console.log('query submitted') + +// 3. The graph container + sigma canvases must appear. +try { + await page.waitForSelector('.graph-container .sigma-graph-outer canvas', { timeout: 20000 }) +} catch (err) { + await page.screenshot({ path: '/tmp/sigma-fail.png' }) + console.error('pageErrors:', JSON.stringify(pageErrors, null, 2)) + console.error('consoleErrors:', JSON.stringify(consoleErrors.slice(0, 10), null, 2)) + const frameText = await page.evaluate(() => { + const f = document.querySelector('.frame-item') || document.body + return f.innerText.slice(0, 600) + }) + console.error('frame text:', frameText) + await die('graph canvas never appeared') +} +// Give the FA2 layout a moment to spread the nodes. +await new Promise((r) => setTimeout(r, 4000)) + +const info = await page.evaluate(() => { + const canvases = document.querySelectorAll('.sigma-graph-outer canvas') + const panel = document.querySelector('.graph-stats') + let webgl = false + for (const c of canvases) { + if (c.getContext('webgl2') || c.getContext('webgl')) webgl = true + } + return { + canvasCount: canvases.length, + webgl, + panelText: panel ? panel.innerText.replace(/\n/g, ' ').slice(0, 200) : null, + } +}) +console.log('render info:', JSON.stringify(info)) + +if (info.canvasCount < 2) await die('expected sigma canvas layers, got ' + info.canvasCount) +if (!info.webgl) await die('no WebGL context on sigma canvases') +if (!/3 nodes(.|\n)*3 edges/.test(info.panelText || '')) + await die('graph stats mismatch: ' + info.panelText) + +// Exercise the new toolbar: search for a node and zoom-to-fit. +await page.$eval('.graph-search input', (el) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value', + ).set + setter.call(el, 'Alice') + el.dispatchEvent(new Event('input', { bubbles: true })) +}) +await page.focus('.graph-search input') +await page.keyboard.press('Enter') +await new Promise((r) => setTimeout(r, 800)) +await page.$eval('.graph-control-btn', (b) => b.click()) +await new Promise((r) => setTimeout(r, 800)) +console.log('search + zoomToFit exercised') + +// 4. Screenshot for the human. +await page.screenshot({ path: '/tmp/sigma-graph.png' }) + +if (pageErrors.length) await die('page errors: ' + pageErrors.join(' | ')) +const realConsoleErrors = consoleErrors.filter( + (e) => !/favicon|manifest|ERR_BLOCKED|sourcemap|404/i.test(e), +) +if (realConsoleErrors.length) + console.warn('console errors (non-fatal):', realConsoleErrors.slice(0, 5)) + +console.log('PASS: sigma graph rendered 3 nodes / 3 edges via WebGL') +await browser.close() diff --git a/client/src/components/D3Graph/D3Graph.scss b/client/src/components/D3Graph/D3Graph.scss deleted file mode 100644 index ecf273ba..00000000 --- a/client/src/components/D3Graph/D3Graph.scss +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -.graph-outer { - position: relative; - width: 100%; - - canvas { - position: absolute; - width: 100%; - height: 100%; - } - - .graph-minimap { - position: absolute; - bottom: 32px; - left: 12px; - width: 180px; - height: 120px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - cursor: crosshair; - z-index: 5; - pointer-events: auto; - opacity: 0.9; - transition: opacity 0.2s; - - &:hover { - opacity: 1; - } - } -} diff --git a/client/src/components/D3Graph/index.js b/client/src/components/D3Graph/index.js deleted file mode 100644 index 2866e145..00000000 --- a/client/src/components/D3Graph/index.js +++ /dev/null @@ -1,1181 +0,0 @@ -/* - * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as d3 from 'd3' -import { event as currentEvent } from 'd3-selection' -import debounce from 'lodash.debounce' -import React from 'react' - -import './D3Graph.scss' - -const ARROW_LENGTH = 8 -const ARROW_WIDTH = 4 - -const MIN_NODE_RADIUS = 8 -const MAX_NODE_RADIUS = 40 -const DEFAULT_NODE_RADIUS = 12 -const DOUBLE_CLICK_MS = 250 - -// Performance thresholds -const PERF = { - LARGE_GRAPH: 300, // nodes: disable expensive effects - HUGE_GRAPH: 800, // nodes: aggressive LOD - MAX_VISIBLE_EDGES: 2000, // max edges to render at once - MINIMAP_INTERVAL: 8, // only redraw minimap every N frames - HULL_INTERVAL: 5, // only recompute hulls every N frames -} - -const THEME = { - nodeBorderActive: 3, - nodeBorderDefault: 1.5, - edgeWidthActive: 3.0, - edgeWidthHighlight: 2.0, - edgeWidthDefault: 1.2, - edgeAlphaDefault: 0.35, - edgeAlphaHighlight: 0.9, - edgeAlphaDimmed: 0.06, - nodeDimmedAlpha: 0.12, - labelFont: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - hullAlpha: 0.07, - hullStrokeAlpha: 0.25, - hullPadding: 30, -} - -function parseHexColor(hex) { - if (!hex || hex[0] !== '#' || hex.length < 7) - return { r: 128, g: 128, b: 128 } - return { - r: parseInt(hex.slice(1, 3), 16), - g: parseInt(hex.slice(3, 5), 16), - b: parseInt(hex.slice(5, 7), 16), - } -} - -function darkenColor(hex, factor = 0.3) { - const { r, g, b } = parseHexColor(hex) - return `rgb(${Math.round(r * (1 - factor))},${Math.round(g * (1 - factor))},${Math.round(b * (1 - factor))})` -} - -function hexToRgba(hex, alpha) { - const { r, g, b } = parseHexColor(hex) - return `rgba(${r},${g},${b},${alpha})` -} - -function roundRect(ctx, x, y, w, h, r) { - const rr = Math.min(r, w / 2, h / 2) - ctx.beginPath() - ctx.moveTo(x + rr, y) - ctx.arcTo(x + w, y, x + w, y + h, rr) - ctx.arcTo(x + w, y + h, x, y + h, rr) - ctx.arcTo(x, y + h, x, y, rr) - ctx.arcTo(x, y, x + w, y, rr) - ctx.closePath() -} - -const fixedPosForce = () => { - const self = { nodes: [] } - const res = function tick() { - for (let i = 0; i < self.nodes.length; i++) { - const n = self.nodes[i] - if (!n._posFixed) continue - n.x = n._posFixed.x - n.y = n._posFixed.y - } - } - res.initialize = (nodes) => (self.nodes = nodes) - res.setNodeCoords = (node, x, y) => { - node._posFixed = { x, y } - node.x = x - node.y = y - } - return res -} - -// ArangoDB-style cluster force — reuses allocations -const forceCluster = (strength = 0.35) => { - let nodes = [] - // Reuse these maps across ticks to avoid GC - const centerX = new Map() - const centerY = new Map() - const counts = new Map() - - function force(alpha) { - // Reset accumulators - centerX.forEach((_, k) => { - centerX.set(k, 0) - centerY.set(k, 0) - counts.set(k, 0) - }) - - for (let i = 0; i < nodes.length; i++) { - const n = nodes[i] - const g = n.group - if (!g) continue - if (!counts.has(g)) { - centerX.set(g, 0) - centerY.set(g, 0) - counts.set(g, 0) - } - centerX.set(g, centerX.get(g) + n.x) - centerY.set(g, centerY.get(g) + n.y) - counts.set(g, counts.get(g) + 1) - } - - // Normalize to centroids - counts.forEach((c, g) => { - if (c > 0) { - centerX.set(g, centerX.get(g) / c) - centerY.set(g, centerY.get(g) / c) - } - }) - - const s = alpha * strength - for (let i = 0; i < nodes.length; i++) { - const n = nodes[i] - if (!n.group || n._posFixed) continue - const cx = centerX.get(n.group) - const cy = centerY.get(n.group) - if (cx === undefined) continue - n.vx += (cx - n.x) * s - n.vy += (cy - n.y) * s - } - } - - force.initialize = (_) => (nodes = _) - force.strength = (s) => { - strength = s - return force - } - return force -} - -export default class D3Graph extends React.Component { - width = 100 - height = 100 - outer = React.createRef() - devicePixelRatio = window.devicePixelRatio || 1 - - state = { - transform: d3.zoomTransform({}), - } - - document = { - nodes: new Map(), - edges: new Map(), - } - - nodeDegrees = new Map() - adjacencyMap = new Map() - groupColors = new Map() - animationFrameId = null - lastFrameTime = 0 - frameCount = 0 - - // Pre-cached per-node render data (avoids per-frame allocation) - nodeRenderCache = new Map() - - // Cached hull paths (recomputed every HULL_INTERVAL frames) - cachedHulls = null - - // Pulse animation state - pulsingNodes = new Map() - - computeNodeDegrees = () => { - this.nodeDegrees.clear() - this.adjacencyMap.clear() - - this.document.nodes.forEach((n) => { - this.nodeDegrees.set(n.id, 0) - this.adjacencyMap.set(n.id, new Set()) - }) - - this.document.edges.forEach((edge) => { - const srcId = - typeof edge.source === 'object' ? edge.source.id : edge.source - const tgtId = - typeof edge.target === 'object' ? edge.target.id : edge.target - this.nodeDegrees.set(srcId, (this.nodeDegrees.get(srcId) || 0) + 1) - this.nodeDegrees.set(tgtId, (this.nodeDegrees.get(tgtId) || 0) + 1) - if (this.adjacencyMap.has(srcId)) this.adjacencyMap.get(srcId).add(tgtId) - if (this.adjacencyMap.has(tgtId)) this.adjacencyMap.get(tgtId).add(srcId) - }) - - this.maxDegree = 1 - this.nodeDegrees.forEach((deg) => { - if (deg > this.maxDegree) this.maxDegree = deg - }) - - this.groupColors.clear() - this.document.nodes.forEach((n) => { - if (n.group && n.color && !this.groupColors.has(n.group)) { - this.groupColors.set(n.group, n.color) - } - }) - - // Pre-cache radius and colors per node (avoids recalc every frame) - this.nodeRenderCache.clear() - this.document.nodes.forEach((n) => { - const degree = this.nodeDegrees.get(n.id) || 0 - let radius = DEFAULT_NODE_RADIUS - if (this.maxDegree > 1) { - const t = Math.sqrt(degree / this.maxDegree) - radius = MIN_NODE_RADIUS + t * (MAX_NODE_RADIUS - MIN_NODE_RADIUS) - } - const color = n.color || '#848484' - this.nodeRenderCache.set(n.id, { - radius, - degree, - color, - colorDark: darkenColor(color, 0.25), - }) - }) - - this.cachedHulls = null // invalidate hull cache - } - - getNodeRadius = (node) => { - const cached = this.nodeRenderCache.get(node.id) - return cached ? cached.radius : DEFAULT_NODE_RADIUS - } - - isInNeighborhood = (nodeId) => { - const hoveredNode = this.props.activeNode - if (!hoveredNode || !this._isHovering) return true - if (nodeId === hoveredNode.id) return true - const neighbors = this.adjacencyMap.get(hoveredNode.id) - return neighbors && neighbors.has(nodeId) - } - - isEdgeInNeighborhood = (edge) => { - const hoveredNode = this.props.activeNode - if (!hoveredNode || !this._isHovering) return true - const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source - const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target - return srcId === hoveredNode.id || tgtId === hoveredNode.id - } - - getWorldViewport = () => { - const k = this.state.transform.k - const tx = this.state.transform.x - const ty = this.state.transform.y - return { - x0: -tx / k, - y0: -ty / k, - x1: (this.width - tx) / k, - y1: (this.height - ty) / k, - } - } - - isInViewport = (x, y, pad = 60) => { - const vp = this._viewport - if (!vp) return true - return ( - x >= vp.x0 - pad && - x <= vp.x1 + pad && - y >= vp.y0 - pad && - y <= vp.y1 + pad - ) - } - - // Hull computation — cached and throttled - computeHulls = () => { - const nodeCount = this.document.nodes.size - if (nodeCount > PERF.HUGE_GRAPH || nodeCount < 4) { - this.cachedHulls = [] - return - } - - const byGroup = new Map() - this.document.nodes.forEach((n) => { - if (!n.group) return - if (!byGroup.has(n.group)) byGroup.set(n.group, []) - byGroup.get(n.group).push([n.x, n.y]) - }) - - const hulls = [] - byGroup.forEach((points, group) => { - if (points.length < 3) return - const hull = d3.polygonHull(points) - if (!hull) return - hulls.push({ hull, color: this.groupColors.get(group) || '#888888' }) - }) - this.cachedHulls = hulls - } - - drawHulls = (context) => { - if (!this.cachedHulls || !this.cachedHulls.length) return - - context.save() - context.lineJoin = 'round' - context.lineWidth = 1.5 - - for (let h = 0; h < this.cachedHulls.length; h++) { - const { hull, color } = this.cachedHulls[h] - context.fillStyle = hexToRgba(color, THEME.hullAlpha) - context.strokeStyle = hexToRgba(color, THEME.hullStrokeAlpha) - - const pad = THEME.hullPadding - context.beginPath() - for (let i = 0; i < hull.length; i++) { - const p0 = hull[(i - 1 + hull.length) % hull.length] - const p1 = hull[i] - const p2 = hull[(i + 1) % hull.length] - const v1x = p1[0] - p0[0], - v1y = p1[1] - p0[1] - const v2x = p2[0] - p1[0], - v2y = p2[1] - p1[1] - const len1 = Math.hypot(v1x, v1y) || 1 - const len2 = Math.hypot(v2x, v2y) || 1 - const pInX = p1[0] - (v1x / len1) * pad, - pInY = p1[1] - (v1y / len1) * pad - const pOutX = p1[0] + (v2x / len2) * pad, - pOutY = p1[1] + (v2y / len2) * pad - if (i === 0) context.moveTo(pInX, pInY) - else context.lineTo(pInX, pInY) - context.quadraticCurveTo(p1[0], p1[1], pOutX, pOutY) - } - context.closePath() - context.fill() - context.stroke() - } - context.restore() - } - - // Minimap — throttled, simple rendering - drawMinimap = () => { - if (!this.minimapCanvas || !this.document.nodes.size) return - // Only redraw minimap every N frames - if (this.frameCount % PERF.MINIMAP_INTERVAL !== 0) return - - const mmw = this.minimapCanvas.width - const mmh = this.minimapCanvas.height - const mmctx = this.minimapContext - - mmctx.clearRect(0, 0, mmw, mmh) - - mmctx.fillStyle = 'rgba(20, 22, 30, 0.85)' - roundRect(mmctx, 0, 0, mmw, mmh, 6) - mmctx.fill() - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - this.document.nodes.forEach((n) => { - if (n.x < minX) minX = n.x - if (n.y < minY) minY = n.y - if (n.x > maxX) maxX = n.x - if (n.y > maxY) maxY = n.y - }) - - const pad = 10 - const gw = maxX - minX || 1 - const gh = maxY - minY || 1 - const sx = (mmw - pad * 2) / gw - const sy = (mmh - pad * 2) / gh - const s = Math.min(sx, sy) - const ox = (mmw - s * gw) / 2 - const oy = (mmh - s * gh) / 2 - - mmctx.save() - mmctx.translate(ox - minX * s, oy - minY * s) - mmctx.scale(s, s) - - // Skip edges in minimap for large graphs - if (this.document.edges.size < 500) { - mmctx.strokeStyle = 'rgba(255,255,255,0.1)' - mmctx.lineWidth = 1 / s - mmctx.beginPath() - this.document.edges.forEach((edge) => { - if (!edge.source.x) return - mmctx.moveTo(edge.source.x, edge.source.y) - mmctx.lineTo(edge.target.x, edge.target.y) - }) - mmctx.stroke() - } - - // Batch node drawing by color for fewer state changes - const dotSize = Math.max(1.5, 2 / s) - mmctx.globalAlpha = 0.8 - // Simple single-pass: just draw all nodes as one color for speed - mmctx.fillStyle = 'rgba(180,190,200,0.9)' - mmctx.beginPath() - this.document.nodes.forEach((n) => { - mmctx.moveTo(n.x + dotSize, n.y) - mmctx.arc(n.x, n.y, dotSize, 0, Math.PI * 2) - }) - mmctx.fill() - mmctx.globalAlpha = 1 - - // Viewport rectangle - const vp = this.getWorldViewport() - mmctx.strokeStyle = 'rgba(80,166,255,0.9)' - mmctx.lineWidth = 2 / s - mmctx.fillStyle = 'rgba(80,166,255,0.08)' - mmctx.strokeRect(vp.x0, vp.y0, vp.x1 - vp.x0, vp.y1 - vp.y0) - mmctx.fillRect(vp.x0, vp.y0, vp.x1 - vp.x0, vp.y1 - vp.y0) - - mmctx.restore() - } - - labelEdge = (context, edge) => { - const zoom = this.state.transform.k * this.devicePixelRatio - if (this.document.edges.size > 200 && zoom < 1.5) return - if (this.document.edges.size > 40 && zoom < 1.0) return - if (zoom < 0.6) return - - const srcR = this.getNodeRadius(edge.source) - const tgtR = this.getNodeRadius(edge.target) - if (edge.arc.distance < srcR + tgtR + 40) return - - const fontSize = 11 - context.font = `500 ${fontSize}px ${THEME.labelFont}` - context.textAlign = 'center' - context.textBaseline = 'middle' - - const maxWidth = 120 - const bgPadding = 4 - let { width } = context.measureText(edge.label) - width = Math.min(width, maxWidth) - - const { centerX: cx, centerY: cy } = edge.arc - const rw = width + 2 * bgPadding - const rh = fontSize + bgPadding - - context.globalAlpha = 0.88 - context.fillStyle = '#ffffff' - roundRect(context, cx - rw / 2, cy - rh / 2, rw, rh, 3) - context.fill() - context.globalAlpha = 1 - - context.fillStyle = '#333333' - context.fillText(edge.label, cx, cy, maxWidth) - } - - labelNode = (context, node) => { - const zoom = this.state.transform.k * this.devicePixelRatio - const nodeCount = this.document.nodes.size - const cached = this.nodeRenderCache.get(node.id) - const degree = cached ? cached.degree : 0 - const radius = cached ? cached.radius : DEFAULT_NODE_RADIUS - - if (nodeCount > 500 && zoom < 2.0 && degree < 3) return - if (nodeCount > 200 && zoom < 1.2 && degree < 2) return - if (nodeCount > 50 && zoom < 0.8) return - if (zoom < 0.4) return - if (nodeCount > 100 && degree < 2 && zoom < 1.5) return - - const label = node.label || '' - if (!label) return - - const fontSize = Math.max(10, Math.min(14, radius * 0.9)) - context.font = `600 ${fontSize}px ${THEME.labelFont}` - context.textAlign = 'center' - context.textBaseline = 'middle' - - const maxWidth = radius * 4 - let { width: textWidth } = context.measureText(label) - textWidth = Math.min(textWidth, maxWidth) - - const bgPadding = 3 - const textY = node.y + radius + fontSize / 2 + 4 - const rw = textWidth + 2 * bgPadding - const rh = fontSize + bgPadding - - context.globalAlpha = 0.88 - context.fillStyle = '#2A2C34' - roundRect(context, node.x - rw / 2, textY - rh / 2, rw, rh, rh / 2) - context.fill() - context.globalAlpha = 1 - - context.fillStyle = '#FFFFFF' - context.fillText(label, node.x, textY, maxWidth) - } - - _drawAll = () => { - const context = this.canvasContext - if (!context) return - - const { highlightPredicate } = this.props - this._isHovering = !!this.props.hoveredNode - this.frameCount++ - - context.save() - const { devicePixelRatio: dpr } = this - context.clearRect(0, 0, this.width * dpr, this.height * dpr) - context.translate( - this.state.transform.x * dpr, - this.state.transform.y * dpr, - ) - context.scale(this.state.transform.k * dpr, this.state.transform.k * dpr) - - this._viewport = this.getWorldViewport() - - const zoom = this.state.transform.k * dpr - const isZoomedOut = zoom < 0.3 - const nodeCount = this.document.nodes.size - const isLargeGraph = nodeCount > PERF.LARGE_GRAPH - const isHugeGraph = nodeCount > PERF.HUGE_GRAPH - - // --- Hulls (throttled) --- - if (!isZoomedOut && !isHugeGraph) { - if (this.frameCount % PERF.HULL_INTERVAL === 0 || !this.cachedHulls) { - this.computeHulls() - } - this.drawHulls(context) - } - - // --- Arc helpers (defined once, not per-edge) --- - const addArrow = (arc, target, vx, vy, nodeRadius) => { - const baseOffset = nodeRadius + ARROW_LENGTH - arc.arrowBase0 = target.x - vx * baseOffset - arc.arrowBase1 = target.y - vy * baseOffset - arc.arrowEnd0 = target.x - nodeRadius * vx - arc.arrowEnd1 = target.y - nodeRadius * vy - arc.arrowPt10 = arc.arrowBase0 + ARROW_WIDTH * vy - arc.arrowPt11 = arc.arrowBase1 - ARROW_WIDTH * vx - arc.arrowPt20 = arc.arrowBase0 - ARROW_WIDTH * vy - arc.arrowPt21 = arc.arrowBase1 + ARROW_WIDTH * vx - arc.hasArrow = true - } - - const getArc = (edge) => { - const dx = edge.target.x - edge.source.x - const dy = edge.target.y - edge.source.y - const l = Math.sqrt(dx * dx + dy * dy) - - const srcR = this.getNodeRadius(edge.source) - const tgtR = this.getNodeRadius(edge.target) - - const arc = { - radius: -1, - distance: l, - centerX: edge.source.x + dx / 2, - centerY: edge.source.y + dy / 2, - hasArrow: false, - } - - if ( - !edge.siblingCount || - edge.siblingCount < 2 || - (edge.siblingCount % 2 && edge.siblingIndex === 0) || - l < srcR + tgtR - ) { - if (l > srcR + tgtR + 2 * ARROW_LENGTH) { - addArrow(arc, edge.target, dx / l, dy / l, tgtR) - } - return arc - } - - const LR = 4 - let offset - if (edge.siblingCount % 2) { - offset = LR * (1 + Math.ceil(edge.siblingIndex / 2) * 2) - } else { - offset = LR * (1 + Math.floor(edge.siblingIndex / 2) * 2) - } - if (edge.siblingCount * LR > (0.9 * l) / 2) { - offset = (offset * 0.9 * l) / 2 / edge.siblingCount / LR - } - - const norm0 = edge.siblingIndex % 2 ? dy / l : -dy / l - const norm1 = edge.siblingIndex % 2 ? -dx / l : dx / l - const R = (offset * offset + (l * l) / 4) / 2 / offset - const h2 = (R * offset) / (R - offset) - - arc.radius = R - arc.centerX = edge.source.x + dx / 2 + norm0 * offset - arc.centerY = edge.source.y + dy / 2 + norm1 * offset - arc.controlX = edge.source.x + dx / 2 + norm0 * (offset + h2) - arc.controlY = edge.source.y + dy / 2 + norm1 * (offset + h2) - - if (l > srcR + tgtR + 2 * ARROW_LENGTH) { - const rotateDir = edge.siblingIndex % 2 ? +1 : -1 - const alpha = Math.asin(Math.min(1, l / 2 / R)) - const theta = Math.asin(Math.min(1, tgtR / 2 / R)) - const ra = rotateDir * (alpha - theta) - const cosA = Math.cos(ra), - sinA = Math.sin(ra) - const ndx = dx / l, - ndy = dy / l - addArrow( - arc, - edge.target, - ndx * cosA - ndy * sinA, - ndx * sinA + ndy * cosA, - tgtR, - ) - } - - return arc - } - - // --- Edges --- - context.lineCap = 'round' - let edgesDrawn = 0 - this.document.edges.forEach((edge) => { - // Hard cap on visible edges for huge graphs - if (isHugeGraph && edgesDrawn >= PERF.MAX_VISIBLE_EDGES) return - - // Viewport culling - if ( - !this.isInViewport(edge.source.x, edge.source.y, 100) && - !this.isInViewport(edge.target.x, edge.target.y, 100) - ) - return - - edgesDrawn++ - const arc = (edge.arc = getArc(edge)) - - const isHighlighted = edge.predicate === highlightPredicate - const isActive = edge === this.props.activeEdge - const inNeighborhood = this.isEdgeInNeighborhood(edge) - - context.strokeStyle = edge.color - if (this._isHovering && !inNeighborhood) { - context.globalAlpha = THEME.edgeAlphaDimmed - context.lineWidth = 0.5 - } else if (isActive) { - context.globalAlpha = THEME.edgeAlphaHighlight - context.lineWidth = THEME.edgeWidthActive - } else if (isHighlighted || (this._isHovering && inNeighborhood)) { - context.globalAlpha = THEME.edgeAlphaHighlight - context.lineWidth = THEME.edgeWidthHighlight - } else { - context.globalAlpha = THEME.edgeAlphaDefault - context.lineWidth = THEME.edgeWidthDefault - } - - context.beginPath() - context.moveTo(edge.source.x, edge.source.y) - if (arc.radius <= 0) { - context.lineTo(edge.target.x, edge.target.y) - } else { - context.arcTo( - arc.controlX, - arc.controlY, - edge.target.x, - edge.target.y, - arc.radius, - ) - } - context.stroke() - - // Arrowhead — skip for huge graphs when zoomed out - if (arc.hasArrow && !(isHugeGraph && isZoomedOut)) { - context.fillStyle = edge.color - context.beginPath() - context.moveTo(arc.arrowEnd0, arc.arrowEnd1) - context.lineTo(arc.arrowPt10, arc.arrowPt11) - context.lineTo(arc.arrowPt20, arc.arrowPt21) - context.closePath() - context.fill() - } - - context.globalAlpha = 1 - if (!isLargeGraph && (!this._isHovering || inNeighborhood)) { - this.labelEdge(context, edge) - } - }) - - // --- Nodes --- - this.document.nodes.forEach((d) => { - if (!this.isInViewport(d.x, d.y, 50)) return - - const cached = this.nodeRenderCache.get(d.id) - const radius = cached ? cached.radius : DEFAULT_NODE_RADIUS - const color = cached ? cached.color : '#848484' - const isActive = d === this.props.activeNode - const inNeighborhood = this.isInNeighborhood(d.id) - const dimmed = this._isHovering && !inNeighborhood - - if (dimmed) context.globalAlpha = THEME.nodeDimmedAlpha - - // LOD: dots at very low zoom - if (isZoomedOut) { - context.fillStyle = color - context.beginPath() - context.arc(d.x, d.y, Math.max(2, radius * 0.3), 0, 2 * Math.PI) - context.fill() - context.globalAlpha = 1 - return - } - - // Solid fill - context.fillStyle = color - context.beginPath() - context.arc(d.x, d.y, radius, 0, 2 * Math.PI, true) - context.fill() - - // Border - context.strokeStyle = cached ? cached.colorDark : darkenColor(color, 0.25) - context.lineWidth = isActive - ? THEME.nodeBorderActive - : THEME.nodeBorderDefault - context.stroke() - - // Active glow - if (isActive && !dimmed) { - context.strokeStyle = color - context.globalAlpha = 0.4 - context.lineWidth = 4 - context.beginPath() - context.arc(d.x, d.y, radius + 4, 0, 2 * Math.PI, true) - context.stroke() - context.globalAlpha = dimmed ? THEME.nodeDimmedAlpha : 1 - } - - // Search pulse - const pulse = this.pulsingNodes.get(d.id) - if (pulse && pulse > 0) { - context.strokeStyle = '#50A6FF' - context.globalAlpha = 0.6 * pulse - context.lineWidth = 8 * pulse - context.beginPath() - context.arc(d.x, d.y, radius + 10 * (1 - pulse), 0, 2 * Math.PI) - context.stroke() - context.globalAlpha = 1 - } - - // Expanded dot - if (d.expanded) { - context.fillStyle = '#ffffff' - context.beginPath() - context.arc(d.x + radius * 0.55, d.y - radius * 0.55, 3, 0, 2 * Math.PI) - context.fill() - } - - context.globalAlpha = 1 - if (!dimmed) this.labelNode(context, d) - }) - - context.restore() - this.drawMinimap() - } - - startAnimationLoop = () => { - const animate = (timestamp) => { - const delta = timestamp - (this.lastFrameTime || timestamp) - this.lastFrameTime = timestamp - - let hasPulse = false - this.pulsingNodes.forEach((val, key) => { - const newVal = val - delta * 0.002 - if (newVal <= 0) this.pulsingNodes.delete(key) - else { - this.pulsingNodes.set(key, newVal) - hasPulse = true - } - }) - - if (hasPulse) this._drawAll() - - this.animationFrameId = requestAnimationFrame(animate) - } - this.animationFrameId = requestAnimationFrame(animate) - } - - drawGraph = debounce(this._drawAll, 5, { leading: true, trailing: true }) - - createForces = () => { - this.d3simulation - .alphaTarget(0) - .alphaMin(0.005) - .alphaDecay(0.05) - .velocityDecay(0.5) - .force( - 'link', - d3 - .forceLink() - .distance((d) => { - const srcDeg = - this.nodeDegrees.get( - typeof d.source === 'object' ? d.source.id : d.source, - ) || 1 - const tgtDeg = - this.nodeDegrees.get( - typeof d.target === 'object' ? d.target.id : d.target, - ) || 1 - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - const cross = srcG && tgtG && srcG !== tgtG - return (cross ? 100 : 60) + Math.sqrt(srcDeg + tgtDeg) * 15 - }) - .strength((d) => { - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - return srcG === tgtG ? 0.4 : 0.15 - }) - .id((d) => d.id), - ) - .force( - 'charge', - d3 - .forceManyBody() - .strength((d) => { - const degree = this.nodeDegrees.get(d.id) || 0 - return -200 - degree * 30 - }) - .distanceMax(500) - .theta(0.9), - ) - .force( - 'collision', - d3 - .forceCollide() - .radius((d) => this.getNodeRadius(d) + 8) - .strength(0.8), - ) - .force('cluster', forceCluster(0.35)) - .force('fixedPosForce', fixedPosForce()) - - this.fixedPosForce = this.d3simulation.force('fixedPosForce') - this.edgesForce = this.d3simulation.force('link') - } - - componentDidMount() { - this.d3simulation = d3.forceSimulation().on('tick', this.drawGraph) - this.createForces() - - this.graphCanvas = d3 - .select(this.outer.current) - .append('canvas') - .attr('width', this.width) - .attr('height', this.height) - .node() - - this.minimapCanvas = document.createElement('canvas') - this.minimapCanvas.className = 'graph-minimap' - this.minimapCanvas.width = 180 - this.minimapCanvas.height = 120 - this.outer.current.appendChild(this.minimapCanvas) - this.minimapContext = this.minimapCanvas.getContext('2d') - this.minimapCanvas.addEventListener('click', this.onMinimapClick) - - this.zoomBehavior = d3 - .zoom() - .scaleExtent([(1 / 8) * this.devicePixelRatio, 6 * this.devicePixelRatio]) - .on('zoom', this.onZoom) - - d3.select(this.graphCanvas) - .on('click', this.onClick) - .on('dblclick', this.onDoubleClick) - .on('mousemove', this.onMouseMove) - .call( - d3 - .drag() - .subject(this.dragsubject) - .on('start', this.dragstarted) - .on('drag', this.dragged), - ) - .call(this.zoomBehavior) - - this.onResize() - this.updateDocument(this.props.nodes, this.props.edges) - this.startAnimationLoop() - this.resizeObserver = window.setInterval(this.onResize, 1000) - } - - componentWillUnmount() { - clearInterval(this.resizeObserver) - if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId) - if (this.minimapCanvas) - this.minimapCanvas.removeEventListener('click', this.onMinimapClick) - } - - onMinimapClick = (e) => { - if (!this.document.nodes.size) return - const rect = this.minimapCanvas.getBoundingClientRect() - const px = e.clientX - rect.left, - py = e.clientY - rect.top - const mmw = this.minimapCanvas.width, - mmh = this.minimapCanvas.height - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - this.document.nodes.forEach((n) => { - if (n.x < minX) minX = n.x - if (n.y < minY) minY = n.y - if (n.x > maxX) maxX = n.x - if (n.y > maxY) maxY = n.y - }) - - const pad = 10, - gw = maxX - minX || 1, - gh = maxY - minY || 1 - const s = Math.min((mmw - pad * 2) / gw, (mmh - pad * 2) / gh) - const ox = (mmw - s * gw) / 2, - oy = (mmh - s * gh) / 2 - const wx = (px - ox + minX * s) / s, - wy = (py - oy + minY * s) / s - - const k = this.state.transform.k - d3.select(this.graphCanvas) - .transition() - .duration(300) - .call( - this.zoomBehavior.transform, - d3.zoomIdentity - .translate(this.width / 2 - wx * k, this.height / 2 - wy * k) - .scale(k), - ) - } - - getD3EventCoords = (event) => this.state.transform.invert([event.x, event.y]) - - findNodeAtPos = (x, y) => { - let minNode, - minD = 1e10 - this.document.nodes.forEach((n) => { - const r = this.getNodeRadius(n) - const d = (n.x - x) * (n.x - x) + (n.y - y) * (n.y - y) - if (d < r * r && d < minD) { - minNode = n - minD = d - } - }) - return minNode - } - - findEdgeAtPos = (x, y) => { - let minEdge, - minD = 1e10 - this.document.edges.forEach((edge) => { - if (!edge.arc) return - const { centerX: cx, centerY: cy } = edge.arc - const d = (cx - x) * (cx - x) + (cy - y) * (cy - y) - if (d < minD) { - minEdge = edge - minD = d - } - }) - return minD > 225 ? undefined : minEdge - } - - onMouseMove = () => { - const { offsetX: x, offsetY: y } = currentEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - this.props.onNodeHovered(node) - if (this.graphCanvas) - this.graphCanvas.style.cursor = node ? 'pointer' : 'default' - if (!node) this.props.onEdgeHovered(this.findEdgeAtPos(...pt)) - this.drawGraph() - } - - onClick = () => { - const { offsetX: x, offsetY: y } = currentEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - if (node) { - currentEvent.stopImmediatePropagation() - return this.props.onNodeSelected(node) - } - const edge = this.findEdgeAtPos(...pt) - if (edge) { - currentEvent.stopImmediatePropagation() - return this.props.onEdgeSelected(edge) - } - } - - onDoubleClick = () => { - const { offsetX: x, offsetY: y } = currentEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - if (node) { - currentEvent.stopImmediatePropagation() - return this.props.onNodeDoubleClicked(node) - } - } - - dragsubject = () => { - const { offsetX: x, offsetY: y } = currentEvent.sourceEvent - const pt = this.getD3EventCoords({ x, y }) - const node = this.findNodeAtPos(...pt) - this.props.onNodeSelected(node) - return node - } - - dragstarted = () => { - if (!currentEvent.active) - setTimeout(() => this.d3simulation.alpha(0.5).restart(), DOUBLE_CLICK_MS) - } - - dragged = () => { - const { offsetX: x, offsetY: y } = currentEvent.sourceEvent - const pt = this.getD3EventCoords({ x, y }) - this.fixedPosForce.setNodeCoords(currentEvent.subject, ...pt) - this.drawGraph() - this.d3simulation.alpha(Math.max(0.12, this.d3simulation.alpha())) - } - - _updateZoom = (transform) => { - if (this.state.transform.toString() !== transform.toString()) - this.setState({ transform }) - } - updateZoom = debounce(this._updateZoom, 2, { leading: true, trailing: true }) - onZoom = () => this.updateZoom(currentEvent.transform) - - zoomToFit = () => { - if (!this.graphCanvas || !this.document.nodes.size) return - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - this.document.nodes.forEach((n) => { - const r = this.getNodeRadius(n) + 20 - if (n.x - r < minX) minX = n.x - r - if (n.y - r < minY) minY = n.y - r - if (n.x + r > maxX) maxX = n.x + r - if (n.y + r > maxY) maxY = n.y + r - }) - const p = 40, - gw = maxX - minX + p * 2, - gh = maxY - minY + p * 2 - const scale = Math.min(this.width / gw, this.height / gh, 2) * 0.9 - const transform = d3.zoomIdentity - .translate(this.width / 2, this.height / 2) - .scale(scale) - .translate(-(minX + maxX) / 2, -(minY + maxY) / 2) - d3.select(this.graphCanvas) - .transition() - .duration(500) - .call(this.zoomBehavior.transform, transform) - } - - focusNode = (node) => { - if (!this.graphCanvas || !node) return - const k = 2.2 - d3.select(this.graphCanvas) - .transition() - .duration(500) - .ease(d3.easeCubicOut) - .call( - this.zoomBehavior.transform, - d3.zoomIdentity - .translate(this.width / 2 - node.x * k, this.height / 2 - node.y * k) - .scale(k), - ) - this.pulsingNodes.set(node.id, 1.0) - } - - searchNode = (query) => { - if (!query) return null - const q = query.toLowerCase().trim() - let found = null - this.document.nodes.forEach((n) => { - if (found) return - const name = (n.name || n.label || '').toLowerCase() - const uid = (n.uid || n.id || '').toLowerCase() - if (name.includes(q) || uid === q) found = n - }) - return found - } - - onResize = () => { - let resized = false - if (this.outer.current) { - const el = this.outer.current - resized |= this.width !== el.offsetWidth - resized |= this.height !== el.offsetHeight - this.width = el.offsetWidth - this.height = el.offsetHeight - } - if (!resized) return - - this.zoomBehavior.scaleTo(d3.select(this.graphCanvas), 1) - this.zoomBehavior.translateTo(d3.select(this.graphCanvas), 0, 0) - - const { width, height } = this - this.d3simulation - .force('x', d3.forceX(0).strength((0.02 * height) / width)) - .force('y', d3.forceY(0).strength((0.02 * width) / height)) - - d3.select(this.graphCanvas) - .attr('width', this.width * this.devicePixelRatio) - .attr('height', this.height * this.devicePixelRatio) - - this.canvasContext = this.graphCanvas.getContext('2d') - this._drawAll() - } - - updateDocument = (nodes, edges) => { - if (!this.d3simulation || !nodes || !edges) return - - const newNodesReceived = - this.document.nodesLength !== nodes.size || - this.document.edgesLength !== edges.size - - this.document = { - edges, - edgesLength: edges.size, - nodes, - nodesLength: nodes.size, - } - this.computeNodeDegrees() - - if (newNodesReceived) { - this.d3simulation - .force( - 'link', - d3 - .forceLink() - .distance((d) => { - const srcDeg = - this.nodeDegrees.get( - typeof d.source === 'object' ? d.source.id : d.source, - ) || 1 - const tgtDeg = - this.nodeDegrees.get( - typeof d.target === 'object' ? d.target.id : d.target, - ) || 1 - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - return ( - (srcG && tgtG && srcG !== tgtG ? 100 : 60) + - Math.sqrt(srcDeg + tgtDeg) * 15 - ) - }) - .strength((d) => { - const srcG = typeof d.source === 'object' ? d.source.group : null - const tgtG = typeof d.target === 'object' ? d.target.group : null - return srcG === tgtG ? 0.4 : 0.15 - }) - .id((d) => d.id), - ) - .force( - 'charge', - d3 - .forceManyBody() - .strength((d) => -200 - (this.nodeDegrees.get(d.id) || 0) * 30) - .distanceMax(500) - .theta(0.9), - ) - .force( - 'collision', - d3 - .forceCollide() - .radius((d) => this.getNodeRadius(d) + 8) - .strength(0.8), - ) - .force('cluster', forceCluster(0.35)) - - this.edgesForce = this.d3simulation.force('link') - this.d3simulation.alpha(0.5).restart() - } - - this.d3simulation.nodes(Array.from(nodes.values())) - this.edgesForce.links(Array.from(edges.values())) - } - - render() { - this.updateDocument(this.props.nodes, this.props.edges) - this.onResize() - this.drawGraph() - return
- } -} diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index 583b0736..ffb49ad4 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -9,8 +9,8 @@ import EdgeProperties from 'components/EdgeProperties' import NodeProperties from 'components/NodeProperties' import PartialRenderInfo from 'components/PartialRenderInfo' -import D3Graph from 'components/D3Graph' import MovablePanel from 'components/MovablePanel' +import SigmaGraph from 'components/SigmaGraph' import '../assets/css/Graph.scss' @@ -91,7 +91,7 @@ export default ({ return (
- + endpoint && typeof endpoint === 'object' ? endpoint.id : endpoint + +/** + * Builds a graphology graph out of the GraphParser datasets. + * + * As a side effect, resolves each edge's source/target from uid strings to + * the node objects from nodesMap. The rest of the app depends on this + * (EdgeProperties renders edge.source.label, GraphParser.collapseNode reads + * edge.source.uid) — d3-force used to perform this mutation. + * + * @param nodesMap Map of uid -> node (GraphParser.nodesDataset) + * @param edgesMap Map of key -> edge (GraphParser.edgesDataset) + * @param prevPositions optional Map of uid -> {x, y} to keep already-placed + * nodes where the user left them across expansions/collapses. + */ +export function buildGraph(nodesMap, edgesMap, prevPositions = new Map()) { + const graph = new MultiDirectedGraph() + if (!nodesMap || !edgesMap) { + return graph + } + + let index = 0 + nodesMap.forEach((node, uid) => { + const pos = prevPositions.get(uid) || initialPosition(index, nodesMap.size) + index++ + graph.addNode(uid, { + label: node.label || node.name || String(uid), + color: node.color || '#cccccc', + size: NODE_SIZE, + x: pos.x, + y: pos.y, + originalNode: node, + }) + }) + + edgesMap.forEach((edge, key) => { + const sourceId = endpointId(edge.source) + const targetId = endpointId(edge.target) + if (!graph.hasNode(sourceId) || !graph.hasNode(targetId)) { + return + } + + // See docstring: keep the d3-force contract of object endpoints. + edge.source = nodesMap.get(sourceId) + edge.target = nodesMap.get(targetId) + + const curvature = edgeCurvature(edge.siblingIndex, edge.siblingCount) + graph.addEdgeWithKey(key, sourceId, targetId, { + label: edge.label, + color: edge.color || '#999999', + size: EDGE_SIZE, + type: curvature === 0 ? 'arrow' : 'curvedArrow', + curvature, + originalEdge: edge, + }) + }) + + // Size nodes by connectivity (like Neo4j Bloom), capped so hubs don't + // swallow the viewport. + graph.forEachNode((uid) => { + graph.setNodeAttribute( + uid, + 'size', + Math.min(NODE_MAX_SIZE, NODE_SIZE + graph.degree(uid) * 0.5), + ) + }) + + return graph +} diff --git a/client/src/components/SigmaGraph/buildGraph.test.js b/client/src/components/SigmaGraph/buildGraph.test.js new file mode 100644 index 00000000..aeca36d7 --- /dev/null +++ b/client/src/components/SigmaGraph/buildGraph.test.js @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildGraph, + edgeCurvature, + EDGE_SIZE, + NODE_MAX_SIZE, + NODE_SIZE, +} from './buildGraph' + +const makeNode = (uid, overrides = {}) => ({ + id: uid, + uid, + label: `label-${uid}`, + color: '#123456', + properties: { attrs: {}, facets: {} }, + ...overrides, +}) + +const makeEdge = (source, target, overrides = {}) => ({ + source, + target, + label: 'friend', + predicate: 'friend', + color: '#654321', + facets: {}, + ...overrides, +}) + +const mapOf = (entries) => new Map(entries) + +describe('buildGraph', () => { + it('returns an empty graph for missing datasets', () => { + expect(buildGraph(null, null).order).toBe(0) + expect(buildGraph(new Map(), new Map()).order).toBe(0) + }) + + it('adds nodes with label, color and original node reference', () => { + const node = makeNode('0x1') + const graph = buildGraph(mapOf([['0x1', node]]), new Map()) + + expect(graph.order).toBe(1) + const attrs = graph.getNodeAttributes('0x1') + expect(attrs.label).toBe('label-0x1') + expect(attrs.color).toBe('#123456') + expect(attrs.size).toBe(NODE_SIZE) + expect(attrs.originalNode).toBe(node) + expect(typeof attrs.x).toBe('number') + expect(typeof attrs.y).toBe('number') + }) + + it('falls back to name, then uid, for unlabeled nodes', () => { + const noLabel = makeNode('0x1', { label: '', name: 'full name' }) + const nothing = makeNode('0x2', { label: '', name: '' }) + const graph = buildGraph( + mapOf([ + ['0x1', noLabel], + ['0x2', nothing], + ]), + new Map(), + ) + + expect(graph.getNodeAttribute('0x1', 'label')).toBe('full name') + expect(graph.getNodeAttribute('0x2', 'label')).toBe('0x2') + }) + + it('resolves edge source/target uids to node objects', () => { + // GraphParser.collapseNode and EdgeProperties depend on edges holding + // node objects, a mutation d3-force used to perform. + const a = makeNode('0x1') + const b = makeNode('0x2') + const edge = makeEdge('0x1', '0x2') + const nodes = mapOf([ + ['0x1', a], + ['0x2', b], + ]) + const graph = buildGraph(nodes, mapOf([['e1', edge]])) + + expect(graph.size).toBe(1) + expect(edge.source).toBe(a) + expect(edge.target).toBe(b) + expect(graph.getEdgeAttribute('e1', 'originalEdge')).toBe(edge) + expect(graph.getEdgeAttribute('e1', 'label')).toBe('friend') + expect(graph.getEdgeAttribute('e1', 'size')).toBe(EDGE_SIZE) + }) + + it('is stable when edges already hold node objects', () => { + const a = makeNode('0x1') + const b = makeNode('0x2') + const edge = makeEdge(a, b) + const nodes = mapOf([ + ['0x1', a], + ['0x2', b], + ]) + const graph = buildGraph(nodes, mapOf([['e1', edge]])) + + expect(graph.size).toBe(1) + expect(edge.source).toBe(a) + expect(edge.target).toBe(b) + }) + + it('skips edges whose endpoints are not in the node map', () => { + const graph = buildGraph( + mapOf([['0x1', makeNode('0x1')]]), + mapOf([['e1', makeEdge('0x1', '0xmissing')]]), + ) + expect(graph.size).toBe(0) + }) + + it('renders parallel edges as distinct curves', () => { + const a = makeNode('0x1') + const b = makeNode('0x2') + const edges = mapOf([ + ['e1', makeEdge('0x1', '0x2', { siblingIndex: 0, siblingCount: 3 })], + ['e2', makeEdge('0x1', '0x2', { siblingIndex: 1, siblingCount: 3 })], + ['e3', makeEdge('0x1', '0x2', { siblingIndex: 2, siblingCount: 3 })], + ]) + const graph = buildGraph( + mapOf([ + ['0x1', a], + ['0x2', b], + ]), + edges, + ) + + expect(graph.size).toBe(3) + expect(graph.getEdgeAttribute('e1', 'type')).toBe('arrow') + expect(graph.getEdgeAttribute('e2', 'type')).toBe('curvedArrow') + expect(graph.getEdgeAttribute('e3', 'type')).toBe('curvedArrow') + + const curvatures = ['e1', 'e2', 'e3'].map((k) => + graph.getEdgeAttribute(k, 'curvature'), + ) + expect(new Set(curvatures).size).toBe(3) + }) + + it('sizes nodes by degree, capped at NODE_MAX_SIZE', () => { + const hub = makeNode('0xhub') + const nodes = [['0xhub', hub]] + const edges = [] + for (let i = 0; i < 30; i++) { + nodes.push([`0x${i}`, makeNode(`0x${i}`)]) + edges.push([`e${i}`, makeEdge('0xhub', `0x${i}`)]) + } + const graph = buildGraph(mapOf(nodes), mapOf(edges)) + + expect(graph.getNodeAttribute('0xhub', 'size')).toBe(NODE_MAX_SIZE) + expect(graph.getNodeAttribute('0x0', 'size')).toBe(NODE_SIZE + 0.5) + }) + + it('reuses previous positions for already-placed nodes', () => { + const graph = buildGraph( + mapOf([ + ['0x1', makeNode('0x1')], + ['0x2', makeNode('0x2')], + ]), + new Map(), + new Map([['0x1', { x: 42, y: -7 }]]), + ) + + expect(graph.getNodeAttribute('0x1', 'x')).toBe(42) + expect(graph.getNodeAttribute('0x1', 'y')).toBe(-7) + expect(graph.getNodeAttribute('0x2', 'x')).not.toBe(42) + }) +}) + +describe('edgeCurvature', () => { + it('keeps single edges straight', () => { + expect(edgeCurvature(0, 1)).toBe(0) + expect(edgeCurvature(undefined, undefined)).toBe(0) + }) + + it('keeps the first of an odd sibling group straight', () => { + expect(edgeCurvature(0, 3)).toBe(0) + }) + + it('fans siblings out on alternating sides', () => { + const three = [0, 1, 2].map((i) => edgeCurvature(i, 3)) + expect(three[1]).toBeGreaterThan(0) + expect(three[2]).toBeLessThan(0) + + const four = [0, 1, 2, 3].map((i) => edgeCurvature(i, 4)) + expect(new Set(four).size).toBe(4) + expect(four.filter((c) => c > 0).length).toBe(2) + expect(four.filter((c) => c < 0).length).toBe(2) + }) +}) diff --git a/client/src/components/SigmaGraph/index.js b/client/src/components/SigmaGraph/index.js new file mode 100644 index 00000000..c90d9183 --- /dev/null +++ b/client/src/components/SigmaGraph/index.js @@ -0,0 +1,269 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import EdgeCurveProgram, { EdgeCurvedArrowProgram } from '@sigma/edge-curve' +import FA2Layout from 'graphology-layout-forceatlas2/worker' +import React from 'react' +import Sigma from 'sigma' +import { EdgeArrowProgram } from 'sigma/rendering' + +import { buildGraph } from './buildGraph' + +import './SigmaGraph.scss' + +const LAYOUT_MS = 4000 +const DIM_COLOR = '#e4e4e4' + +// WebGL renderer for query results, replacing the d3-force canvas renderer. +// Same contract as the old D3Graph component: nodes/edges are the live Maps +// from GraphParser, callbacks receive the original node/edge objects. +export default class SigmaGraph extends React.Component { + containerRef = React.createRef() + + componentDidMount() { + this.graph = buildGraph(this.props.nodes, this.props.edges) + + this.renderer = new Sigma(this.graph, this.containerRef.current, { + defaultEdgeType: 'arrow', + edgeProgramClasses: { + arrow: EdgeArrowProgram, + curved: EdgeCurveProgram, + curvedArrow: EdgeCurvedArrowProgram, + }, + enableEdgeEvents: true, + renderEdgeLabels: true, + labelDensity: 0.8, + labelGridCellSize: 80, + labelFont: 'sans-serif', + labelSize: 12, + edgeLabelSize: 10, + labelRenderedSizeThreshold: 5, + minCameraRatio: 0.05, + maxCameraRatio: 20, + nodeReducer: this.nodeReducer, + edgeReducer: this.edgeReducer, + }) + + this.bindEvents() + this.startLayout() + + this.datasetSignature = this.signature(this.props) + } + + componentDidUpdate() { + const signature = this.signature(this.props) + if (signature !== this.datasetSignature) { + this.datasetSignature = signature + this.syncGraph() + } else { + // Only selection/highlight props changed. + this.renderer.refresh({ skipIndexation: true }) + } + } + + componentWillUnmount() { + this.stopLayout() + if (this.renderer) { + this.renderer.kill() + } + } + + signature = (props) => + [ + props.nodes ? props.nodes.size : 0, + props.edges ? props.edges.size : 0, + props.graphUpdateHack, + ].join('/') + + // --- public API used via ref by GraphContainer ----------------------- + + zoomToFit = () => { + if (this.renderer) { + this.renderer.getCamera().animatedReset({ duration: 500 }) + } + } + + focusNode = (node) => { + if (!this.renderer || !node) { + return + } + const uid = node.id || node.uid + if (!this.graph.hasNode(uid)) { + return + } + const { x, y } = this.renderer.getNodeDisplayData(uid) + this.renderer.getCamera().animate({ x, y, ratio: 0.35 }, { duration: 500 }) + } + + searchNode = (query) => { + if (!query || !this.props.nodes) { + return null + } + const q = query.toLowerCase().trim() + let found = null + this.props.nodes.forEach((n) => { + if (found) { + return + } + const name = (n.name || n.label || '').toLowerCase() + const uid = (n.uid || n.id || '').toLowerCase() + if (name.includes(q) || uid === q) { + found = n + } + }) + return found + } + + syncGraph = () => { + // Carry positions over so expanding/collapsing doesn't reshuffle nodes + // the user already arranged. + const prevPositions = new Map() + this.graph.forEachNode((uid, attrs) => + prevPositions.set(uid, { x: attrs.x, y: attrs.y }), + ) + + const next = buildGraph(this.props.nodes, this.props.edges, prevPositions) + this.graph.clear() + this.graph.import(next) + this.startLayout() + } + + startLayout = () => { + this.stopLayout() + if (this.graph.order < 2) { + return + } + + this.layout = new FA2Layout(this.graph, { + settings: { + gravity: 1, + scalingRatio: 12, + slowDown: 5, + strongGravityMode: true, + edgeWeightInfluence: 0, + }, + }) + this.layout.start() + this.layoutTimer = window.setTimeout(this.stopLayout, LAYOUT_MS) + } + + stopLayout = () => { + if (this.layoutTimer) { + window.clearTimeout(this.layoutTimer) + this.layoutTimer = null + } + if (this.layout) { + this.layout.kill() + this.layout = null + } + } + + // --- highlighting --------------------------------------------------- + + hoveredNode = null + + nodeReducer = (uid, attrs) => { + const { activeNode } = this.props + const res = { ...attrs } + + if (activeNode && attrs.originalNode === activeNode) { + res.highlighted = true + } + + if (this.hoveredNode && uid !== this.hoveredNode) { + if (!this.graph.areNeighbors(uid, this.hoveredNode)) { + res.color = DIM_COLOR + res.label = null + } + } + return res + } + + edgeReducer = (key, attrs) => { + const { activeEdge, highlightPredicate } = this.props + const res = { ...attrs } + const edge = attrs.originalEdge + + if (highlightPredicate && edge.predicate === highlightPredicate) { + res.size = attrs.size * 2 + } + if (activeEdge && edge === activeEdge) { + res.size = attrs.size * 2.5 + res.zIndex = 1 + } + if (this.hoveredNode) { + const [source, target] = this.graph.extremities(key) + if (source !== this.hoveredNode && target !== this.hoveredNode) { + res.color = DIM_COLOR + res.label = null + } + } + return res + } + + // --- events ---------------------------------------------------------- + + bindEvents = () => { + const renderer = this.renderer + + renderer.on('enterNode', ({ node }) => { + this.hoveredNode = node + this.props.onNodeHovered(this.originalNode(node)) + renderer.refresh({ skipIndexation: true }) + }) + renderer.on('leaveNode', () => { + this.hoveredNode = null + this.props.onNodeHovered(null) + renderer.refresh({ skipIndexation: true }) + }) + renderer.on('clickNode', ({ node }) => + this.props.onNodeSelected(this.originalNode(node)), + ) + renderer.on('doubleClickNode', (e) => { + e.preventSigmaDefault() + this.props.onNodeDoubleClicked(this.originalNode(e.node)) + }) + + renderer.on('enterEdge', ({ edge }) => + this.props.onEdgeHovered(this.originalEdge(edge)), + ) + renderer.on('leaveEdge', () => this.props.onEdgeHovered(null)) + renderer.on('clickEdge', ({ edge }) => + this.props.onEdgeSelected(this.originalEdge(edge)), + ) + + renderer.on('clickStage', () => this.props.onNodeSelected(null)) + + // Node dragging. + renderer.on('downNode', (e) => { + this.draggedNode = e.node + if (!renderer.getCustomBBox()) { + renderer.setCustomBBox(renderer.getBBox()) + } + }) + renderer.on('moveBody', ({ event }) => { + if (!this.draggedNode) { + return + } + const pos = renderer.viewportToGraph(event) + this.graph.setNodeAttribute(this.draggedNode, 'x', pos.x) + this.graph.setNodeAttribute(this.draggedNode, 'y', pos.y) + + event.preventSigmaDefault() + event.original.preventDefault() + event.original.stopPropagation() + }) + const endDrag = () => (this.draggedNode = null) + renderer.on('upNode', endDrag) + renderer.on('upStage', endDrag) + } + + originalNode = (uid) => this.graph.getNodeAttribute(uid, 'originalNode') + originalEdge = (key) => this.graph.getEdgeAttribute(key, 'originalEdge') + + render() { + return
+ } +} From 825cf76130028cfeffe121dd2cdce1208d11c167 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:36:11 -0400 Subject: [PATCH 2/9] feat: graph style rules, layout switcher and legend filtering Three graph-view features on top of the sigma renderer: - Style rules (Neo4j Bloom-style): a Graph styles panel in the toolbar lists every group in the current result with a color picker and node size slider; overrides apply live via sigma reducers and persist in localStorage. lib/graphStyles.js (sanitize/persist/merge) has 11 unit tests. - Layout switcher: Force (ForceAtlas2 worker), Circular, and Packed (circlepack clustered by group) via a toolbar select; static layouts auto-fit the camera. - Legend filtering: clicking a predicate chip in the entity selector hides/shows that predicate's nodes and edges without re-querying; hidden chips render dimmed with strikethrough. Verified in a real browser against Dgraph v25: layouts switch, style panel renders per-group rows and applies color changes, legend chips toggle - zero page errors. Unit suite green, production build passes. Co-Authored-By: Claude Fable 5 --- client/src/assets/css/Graph.scss | 18 ++++ client/src/components/EntitySelector.js | 41 +++++-- .../components/FrameLayout/FrameSession.js | 18 ++++ client/src/components/GraphContainer.js | 67 ++++++++++++ client/src/components/GraphStylePanel.js | 88 +++++++++++++++ client/src/components/GraphStylePanel.scss | 84 +++++++++++++++ client/src/components/Label.js | 5 +- .../src/components/SigmaGraph/buildGraph.js | 2 + client/src/components/SigmaGraph/index.js | 60 ++++++++++- client/src/lib/graphStyles.js | 69 ++++++++++++ client/src/lib/graphStyles.test.js | 101 ++++++++++++++++++ 11 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 client/src/components/GraphStylePanel.js create mode 100644 client/src/components/GraphStylePanel.scss create mode 100644 client/src/lib/graphStyles.js create mode 100644 client/src/lib/graphStyles.test.js diff --git a/client/src/assets/css/Graph.scss b/client/src/assets/css/Graph.scss index 6de7c1d9..2def8ea4 100644 --- a/client/src/assets/css/Graph.scss +++ b/client/src/assets/css/Graph.scss @@ -126,6 +126,24 @@ } } +.graph-layout-select { + height: 36px; + border: 1px solid #ddd; + border-radius: 6px; + background: rgba(255, 255, 255, 0.95); + color: #555; + cursor: pointer; + padding: 0 6px; + font-size: 13px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + + &:hover { + background: #fff; + color: #333; + border-color: #bbb; + } +} + .graph-stats { position: absolute; bottom: 8px; diff --git a/client/src/components/EntitySelector.js b/client/src/components/EntitySelector.js index c56c3ea8..f1d2df90 100644 --- a/client/src/components/EntitySelector.js +++ b/client/src/components/EntitySelector.js @@ -15,7 +15,12 @@ export default class EntitySelector extends React.Component { state = { expanded: false } render() { - const { graphLabels, onPredicateHovered } = this.props + const { + graphLabels, + onPredicateHovered, + hiddenPredicates, + onPredicateToggled, + } = this.props const { expanded } = this.state return ( @@ -26,16 +31,30 @@ export default class EntitySelector extends React.Component { > ▲ - {graphLabels.map((label) => ( -
) } diff --git a/client/src/components/FrameLayout/FrameSession.js b/client/src/components/FrameLayout/FrameSession.js index d337e851..bf3038fa 100644 --- a/client/src/components/FrameLayout/FrameSession.js +++ b/client/src/components/FrameLayout/FrameSession.js @@ -42,6 +42,21 @@ export default function FrameSession({ frame, tabResult }) { dispatch(setPanelMinimized(minimized)) const [hoveredPredicate, setHoveredPredicate] = React.useState(null) + const [hiddenPredicates, setHiddenPredicates] = React.useState( + () => new Set(), + ) + + const togglePredicateHidden = (pred) => { + setHiddenPredicates((prev) => { + const next = new Set(prev) + if (next.has(pred)) { + next.delete(pred) + } else { + next.add(pred) + } + return next + }) + } // TODO: updating graphUpdateHack will force Graphcontainer > D3Graph // to re-render, and before render it will refresh nodes/edges dataset. @@ -123,10 +138,13 @@ export default function FrameSession({ frame, tabResult }) { panelHeight={panelHeight} panelWidth={panelWidth} remainingNodes={graph.remainingNodes} + hiddenPredicates={hiddenPredicates} /> ) diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index ffb49ad4..958ed206 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -9,11 +9,20 @@ import EdgeProperties from 'components/EdgeProperties' import NodeProperties from 'components/NodeProperties' import PartialRenderInfo from 'components/PartialRenderInfo' +import GraphStylePanel from 'components/GraphStylePanel' import MovablePanel from 'components/MovablePanel' import SigmaGraph from 'components/SigmaGraph' +import { loadStyleRules, saveStyleRules } from '../lib/graphStyles' + import '../assets/css/Graph.scss' +const LAYOUTS = [ + ['force', 'Force'], + ['circular', 'Circular'], + ['circlepack', 'Packed'], +] + export default ({ graphUpdateHack, edgesDataset, @@ -28,6 +37,7 @@ export default ({ panelHeight, panelWidth, remainingNodes, + hiddenPredicates, }) => { const [selectedNode, setSelectedNode] = React.useState(null) const [hoveredNode, setHoveredNode] = React.useState(null) @@ -38,6 +48,29 @@ export default ({ const [searchQuery, setSearchQuery] = React.useState('') const [searchFocused, setSearchFocused] = React.useState(false) + const [layout, setLayout] = React.useState('force') + const [styleRules, setStyleRules] = React.useState(loadStyleRules) + const [stylePanelOpen, setStylePanelOpen] = React.useState(false) + + const handleStyleChange = (rules) => { + setStyleRules(rules) + saveStyleRules(rules) + } + + const styleGroups = React.useMemo(() => { + const groups = new Map() + nodesDataset.forEach((node) => { + if (node.group && !groups.has(node.group)) { + groups.set(node.group, node.color || '#cccccc') + } + }) + return Array.from(groups, ([group, color]) => ({ group, color })).sort( + (a, b) => a.group.localeCompare(b.group), + ) + // graphUpdateHack changes when the (mutable) dataset Maps change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesDataset, graphUpdateHack]) + const graphRef = React.useRef(null) const onEdgeSelected = (edge) => { @@ -107,6 +140,9 @@ export default ({ activeNode={activeNode} activeEdge={activeEdge} hoveredNode={hoveredNode} + layout={layout} + styleRules={styleRules} + hiddenPredicates={hiddenPredicates} /> {/* Graph toolbar: search + controls */} @@ -140,8 +176,39 @@ export default ({ + +
+ {stylePanelOpen && ( + setStylePanelOpen(false)} + /> + )} + {/* Node/edge count indicator */}
{nodesDataset.size} nodes · {edgesDataset.size} edges diff --git a/client/src/components/GraphStylePanel.js b/client/src/components/GraphStylePanel.js new file mode 100644 index 00000000..e44ab22d --- /dev/null +++ b/client/src/components/GraphStylePanel.js @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' + +import { + MAX_NODE_SIZE, + MIN_NODE_SIZE, + updateRule, +} from '../lib/graphStyles' + +import './GraphStylePanel.scss' + +// Per-group style overrides (color / node size), Neo4j Bloom style. +// Groups come from the predicates that introduced each node. +export default function GraphStylePanel({ + groups, + styleRules, + onChange, + onClose, +}) { + const handleColor = (group, color) => + onChange(updateRule(styleRules, group, { color })) + + const handleSize = (group, size) => + onChange(updateRule(styleRules, group, { size: Number(size) })) + + const handleReset = (group) => { + const next = { ...styleRules } + delete next[group] + onChange(next) + } + + return ( +
+
+ Graph styles + +
+ {groups.length === 0 ? ( +
No groups in this graph
+ ) : ( + groups.map(({ group, color }) => { + const rule = styleRules[group] || {} + return ( +
+ + {group} + + handleColor(group, e.target.value)} + /> + handleSize(group, e.target.value)} + /> + +
+ ) + }) + )} +
+ ) +} diff --git a/client/src/components/GraphStylePanel.scss b/client/src/components/GraphStylePanel.scss new file mode 100644 index 00000000..b3566fc6 --- /dev/null +++ b/client/src/components/GraphStylePanel.scss @@ -0,0 +1,84 @@ +.graph-style-panel { + position: absolute; + top: 52px; + right: 12px; + z-index: 11; + width: 260px; + max-height: 60%; + overflow-y: auto; + + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + padding: 8px 10px; + font-size: 13px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + margin-bottom: 6px; + } + + &__close { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #888; + + &:hover { + color: #333; + } + } + + &__empty { + color: #888; + padding: 6px 0; + } + + &__row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + + input[type='color'] { + width: 26px; + height: 22px; + padding: 0; + border: 1px solid #ccc; + border-radius: 3px; + flex: none; + } + + input[type='range'] { + flex: 1; + min-width: 60px; + } + } + + &__name { + flex: none; + width: 90px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__reset { + border: none; + background: none; + cursor: pointer; + color: #666; + flex: none; + + &:disabled { + color: #ccc; + cursor: default; + } + } +} diff --git a/client/src/components/Label.js b/client/src/components/Label.js index 32ec4106..a558b8c7 100644 --- a/client/src/components/Label.js +++ b/client/src/components/Label.js @@ -26,12 +26,15 @@ function getRGBComponents(color) { } } -export default ({ color, pred, label, ...domProps }) => ( +export default ({ color, pred, label, hidden, ...domProps }) => ( {stylePanelOpen && ( @@ -256,6 +318,23 @@ export default ({ {remainingNodes > 0 && ` · ${remainingNodes} hidden`}
+ {pathMode && ( +
+ + {pathMessage || 'Pick a source node, then a target'} + + {pathResult && ( + + )} +
+ )} + {!remainingNodes ? null : ( findPath(this.graph, source, target) + searchNode = (query) => { if (!query || !this.props.nodes) { return null @@ -191,7 +194,7 @@ export default class SigmaGraph extends React.Component { } nodeReducer = (uid, attrs) => { - const { activeNode, styleRules, colorBy, sizeBy } = this.props + const { activeNode, styleRules, colorBy, sizeBy, pathNodes } = this.props const res = { ...attrs } const group = attrs.originalNode && attrs.originalNode.group @@ -224,6 +227,19 @@ export default class SigmaGraph extends React.Component { res.highlighted = true } + // A computed path dominates hover/selection dimming so the route stays + // legible while the rest of the graph fades back. + if (pathNodes && pathNodes.size) { + if (pathNodes.has(uid)) { + res.highlighted = true + res.zIndex = 1 + } else { + res.color = DIM_COLOR + res.label = null + } + return res + } + if (this.hoveredNode && uid !== this.hoveredNode) { if (!this.graph.areNeighbors(uid, this.hoveredNode)) { res.color = DIM_COLOR @@ -234,7 +250,7 @@ export default class SigmaGraph extends React.Component { } edgeReducer = (key, attrs) => { - const { activeEdge, highlightPredicate, styleRules } = this.props + const { activeEdge, highlightPredicate, styleRules, pathEdges } = this.props const res = { ...attrs } const edge = attrs.originalEdge @@ -255,6 +271,18 @@ export default class SigmaGraph extends React.Component { res.size = attrs.size * 2.5 res.zIndex = 1 } + + if (pathEdges && pathEdges.size) { + if (pathEdges.has(key)) { + res.size = attrs.size * 2.5 + res.zIndex = 1 + } else { + res.color = DIM_COLOR + res.label = null + } + return res + } + if (this.hoveredNode) { const [source, target] = this.graph.extremities(key) if (source !== this.hoveredNode && target !== this.hoveredNode) { diff --git a/client/src/lib/graphPath.js b/client/src/lib/graphPath.js new file mode 100644 index 00000000..9ceb5d7b --- /dev/null +++ b/client/src/lib/graphPath.js @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Returns the first edge key joining a and b in either direction, or null. +function edgeBetween(graph, a, b) { + const keys = [...graph.edges(a, b), ...graph.edges(b, a)] + return keys.length ? keys[0] : null +} + +/** + * Breadth-first shortest path between two nodes of a graphology graph, + * treating edges as undirected (the natural expectation when exploring a + * graph visually). Operates on the currently rendered subgraph, so it finds + * connections among the nodes the user can actually see. + * + * @returns {{nodes: Set, edges: Set, hops: number} | null} + * null when either endpoint is missing or no path exists. + */ +export function findPath(graph, source, target) { + if (!graph || !graph.hasNode(source) || !graph.hasNode(target)) { + return null + } + if (source === target) { + return { nodes: new Set([source]), edges: new Set(), hops: 0 } + } + + const prev = new Map([[source, null]]) + const queue = [source] + let head = 0 + let reached = false + + while (head < queue.length && !reached) { + const current = queue[head++] + graph.forEachNeighbor(current, (neighbor) => { + if (!prev.has(neighbor)) { + prev.set(neighbor, current) + queue.push(neighbor) + if (neighbor === target) { + reached = true + } + } + }) + } + + if (!prev.has(target)) { + return null + } + + const path = [] + for (let node = target; node != null; node = prev.get(node)) { + path.push(node) + } + path.reverse() + + const nodes = new Set(path) + const edges = new Set() + for (let i = 0; i < path.length - 1; i++) { + const key = edgeBetween(graph, path[i], path[i + 1]) + if (key) { + edges.add(key) + } + } + + return { nodes, edges, hops: path.length - 1 } +} diff --git a/client/src/lib/graphPath.test.js b/client/src/lib/graphPath.test.js new file mode 100644 index 00000000..a4019df8 --- /dev/null +++ b/client/src/lib/graphPath.test.js @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MultiDirectedGraph } from 'graphology' + +import { findPath } from './graphPath' + +const makeGraph = (nodes, edges) => { + const g = new MultiDirectedGraph() + nodes.forEach((n) => g.addNode(n)) + edges.forEach(([s, t], i) => g.addEdgeWithKey(`${s}-${t}-${i}`, s, t)) + return g +} + +describe('findPath', () => { + it('returns null when an endpoint is missing', () => { + const g = makeGraph(['a'], []) + expect(findPath(g, 'a', 'z')).toBeNull() + expect(findPath(g, 'z', 'a')).toBeNull() + expect(findPath(null, 'a', 'b')).toBeNull() + }) + + it('returns a zero-hop path for identical endpoints', () => { + const g = makeGraph(['a'], []) + const path = findPath(g, 'a', 'a') + expect(path.hops).toBe(0) + expect([...path.nodes]).toEqual(['a']) + expect(path.edges.size).toBe(0) + }) + + it('finds the shortest path and collects its edges', () => { + // a-b-c-d chain plus a long detour a-e-f-d. + const g = makeGraph( + ['a', 'b', 'c', 'd', 'e', 'f'], + [ + ['a', 'b'], + ['b', 'c'], + ['c', 'd'], + ['a', 'e'], + ['e', 'f'], + ['f', 'd'], + ], + ) + const path = findPath(g, 'a', 'd') + expect(path.hops).toBe(3) + expect(path.nodes.has('a')).toBe(true) + expect(path.nodes.has('d')).toBe(true) + expect(path.edges.size).toBe(3) + }) + + it('traverses edges regardless of direction', () => { + // Edges all point toward 'a', but an undirected path still exists. + const g = makeGraph( + ['a', 'b', 'c'], + [ + ['b', 'a'], + ['c', 'b'], + ], + ) + const path = findPath(g, 'a', 'c') + expect(path.hops).toBe(2) + expect(path.edges.size).toBe(2) + }) + + it('returns null for disconnected nodes', () => { + const g = makeGraph( + ['a', 'b', 'c', 'd'], + [ + ['a', 'b'], + ['c', 'd'], + ], + ) + expect(findPath(g, 'a', 'd')).toBeNull() + }) + + it('picks the shorter of two routes', () => { + // a directly connects to d, and also via b-c. + const g = makeGraph( + ['a', 'b', 'c', 'd'], + [ + ['a', 'd'], + ['a', 'b'], + ['b', 'c'], + ['c', 'd'], + ], + ) + const path = findPath(g, 'a', 'd') + expect(path.hops).toBe(1) + }) +}) From 8e691889afdef9c325a0dd3982b775c8a8bf449e Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Mon, 15 Jun 2026 12:39:31 -0400 Subject: [PATCH 7/9] feat: faceted attribute filtering of the graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a filter panel to the graph toolbar that hides nodes failing a connectivity (degree) range or an attribute predicate (contains / = / ≠ / > / < / exists). Edges drop out with either endpoint. The panel previews how many nodes are hidden and clears in one click. Filtering is applied in the SigmaGraph reducers via a precomputed hidden-node set, recomputed only when the filter spec or dataset changes. Co-Authored-By: Claude Fable 5 --- client/src/components/GraphContainer.js | 58 ++++++++ client/src/components/GraphFilterPanel.js | 118 +++++++++++++++++ client/src/components/GraphFilterPanel.scss | 103 +++++++++++++++ client/src/components/SigmaGraph/index.js | 39 +++++- client/src/lib/graphFilter.js | 118 +++++++++++++++++ client/src/lib/graphFilter.test.js | 139 ++++++++++++++++++++ 6 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 client/src/components/GraphFilterPanel.js create mode 100644 client/src/components/GraphFilterPanel.scss create mode 100644 client/src/lib/graphFilter.js create mode 100644 client/src/lib/graphFilter.test.js diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index 9724c0c5..f80cfdba 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -9,10 +9,16 @@ import EdgeProperties from 'components/EdgeProperties' import NodeProperties from 'components/NodeProperties' import PartialRenderInfo from 'components/PartialRenderInfo' +import GraphFilterPanel from 'components/GraphFilterPanel' import GraphStylePanel from 'components/GraphStylePanel' import MovablePanel from 'components/MovablePanel' import SigmaGraph from 'components/SigmaGraph' +import { + EMPTY_FILTER, + collectAttributeKeys, + nodeMatchesFilter, +} from '../lib/graphFilter' import { loadStyleRules, saveStyleRules } from '../lib/graphStyles' import '../assets/css/Graph.scss' @@ -71,6 +77,10 @@ export default ({ const [pathResult, setPathResult] = React.useState(null) const [pathMessage, setPathMessage] = React.useState(null) + // Faceted filtering: hide nodes outside a degree range / attribute predicate. + const [filter, setFilter] = React.useState(EMPTY_FILTER) + const [filterPanelOpen, setFilterPanelOpen] = React.useState(false) + const handleStyleChange = (rules) => { setStyleRules(rules) saveStyleRules(rules) @@ -90,6 +100,33 @@ export default ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodesDataset, graphUpdateHack]) + const attributeKeys = React.useMemo( + () => collectAttributeKeys(nodesDataset), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodesDataset, graphUpdateHack], + ) + + // Node degree from the edge dataset, tolerating either uid-string or + // resolved-object endpoints, used to preview how many nodes the filter hides. + const hiddenCount = React.useMemo(() => { + const endpointId = (x) => (x && typeof x === 'object' ? x.id || x.uid : x) + const degree = new Map() + edgesDataset.forEach((edge) => { + ;[endpointId(edge.source), endpointId(edge.target)].forEach((id) => { + degree.set(id, (degree.get(id) || 0) + 1) + }) + }) + let hidden = 0 + nodesDataset.forEach((node) => { + const id = node.id || node.uid + if (!nodeMatchesFilter(node, degree.get(id) || 0, filter)) { + hidden++ + } + }) + return hidden + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesDataset, edgesDataset, filter, graphUpdateHack]) + const graphRef = React.useRef(null) const onEdgeSelected = (edge) => { @@ -208,6 +245,7 @@ export default ({ sizeBy={sizeBy} styleRules={styleRules} hiddenPredicates={hiddenPredicates} + filter={filter} pathNodes={pathResult && pathResult.nodes} pathEdges={pathResult && pathResult.edges} /> @@ -301,6 +339,16 @@ export default ({ +
{stylePanelOpen && ( @@ -312,6 +360,16 @@ export default ({ /> )} + {filterPanelOpen && ( + setFilterPanelOpen(false)} + /> + )} + {/* Node/edge count indicator */}
{nodesDataset.size} nodes · {edgesDataset.size} edges diff --git a/client/src/components/GraphFilterPanel.js b/client/src/components/GraphFilterPanel.js new file mode 100644 index 00000000..1787deec --- /dev/null +++ b/client/src/components/GraphFilterPanel.js @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react' + +import { + EMPTY_FILTER, + FILTER_OPERATORS, + filterActive, +} from '../lib/graphFilter' + +import './GraphFilterPanel.scss' + +// Faceted filtering of the rendered graph: hide nodes that fall outside a +// connectivity range or fail an attribute predicate. +export default function GraphFilterPanel({ + attributeKeys, + filter, + hiddenCount, + onChange, + onClose, +}) { + const set = (patch) => onChange({ ...filter, ...patch }) + const op = FILTER_OPERATORS.find(([value]) => value === filter.op) + const opNeedsValue = op ? op[2] : true + + return ( +
+
+ Filter + +
+ +
Connectivity (degree)
+
+ set({ degreeMin: e.target.value })} + /> + + set({ degreeMax: e.target.value })} + /> +
+ +
Attribute
+
+ + +
+ {opNeedsValue && ( +
+ set({ value: e.target.value })} + /> +
+ )} + +
+ + {filterActive(filter) ? `${hiddenCount} hidden` : 'No filter applied'} + + +
+
+ ) +} diff --git a/client/src/components/GraphFilterPanel.scss b/client/src/components/GraphFilterPanel.scss new file mode 100644 index 00000000..0352d58c --- /dev/null +++ b/client/src/components/GraphFilterPanel.scss @@ -0,0 +1,103 @@ +.graph-filter-panel { + position: absolute; + top: 52px; + left: 12px; + z-index: 11; + width: 240px; + + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + padding: 8px 10px; + font-size: 13px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + margin-bottom: 6px; + } + + &__close { + border: none; + background: none; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #888; + + &:hover { + color: #333; + } + } + + &__section { + color: #888; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 8px 0 4px; + } + + &__row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + + input, + select { + flex: 1; + min-width: 0; + height: 28px; + border: 1px solid #ccc; + border-radius: 4px; + padding: 0 6px; + font-size: 13px; + } + + input[type="number"] { + width: 60px; + } + } + + &__dash { + flex: none; + color: #888; + } + + &__footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid #eee; + } + + &__count { + color: #888; + font-size: 12px; + } + + &__clear { + border: none; + background: #4e79a7; + color: #fff; + font-size: 12px; + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background: #3f6690; + } + + &:disabled { + background: #ccc; + cursor: default; + } + } +} diff --git a/client/src/components/SigmaGraph/index.js b/client/src/components/SigmaGraph/index.js index 51f22999..88c1d4c2 100644 --- a/client/src/components/SigmaGraph/index.js +++ b/client/src/components/SigmaGraph/index.js @@ -10,6 +10,7 @@ import React from 'react' import Sigma from 'sigma' import { EdgeArrowProgram } from 'sigma/rendering' +import { filterActive, nodeMatchesFilter } from '../../lib/graphFilter' import { communityColor, metricNodeSize } from '../../lib/graphMetrics' import { findPath } from '../../lib/graphPath' @@ -28,6 +29,7 @@ export default class SigmaGraph extends React.Component { componentDidMount() { this.graph = buildGraph(this.props.nodes, this.props.edges) + this.recomputeFilter() this.renderer = new Sigma(this.graph, this.containerRef.current, { defaultEdgeType: 'arrow', @@ -65,6 +67,9 @@ export default class SigmaGraph extends React.Component { this.applyLayout() } else { // Only selection/highlight/style/filter props changed. + if (prevProps.filter !== this.props.filter) { + this.recomputeFilter() + } this.renderer.refresh({ skipIndexation: true }) } } @@ -135,9 +140,33 @@ export default class SigmaGraph extends React.Component { const next = buildGraph(this.props.nodes, this.props.edges, prevPositions) this.graph.clear() this.graph.import(next) + this.recomputeFilter() this.applyLayout() } + // Set of node ids hidden by the active attribute/degree filter. Recomputed + // whenever the filter spec or the dataset changes, so the per-element + // reducers stay cheap membership tests. + filterHidden = new Set() + + recomputeFilter = () => { + const { filter } = this.props + const hidden = new Set() + if (filterActive(filter)) { + this.graph.forEachNode((uid, attrs) => { + const matches = nodeMatchesFilter( + attrs.originalNode, + this.graph.degree(uid), + filter, + ) + if (!matches) { + hidden.add(uid) + } + }) + } + this.filterHidden = hidden + } + applyLayout = () => { const layout = this.props.layout || 'force' this.stopLayout() @@ -198,7 +227,7 @@ export default class SigmaGraph extends React.Component { const res = { ...attrs } const group = attrs.originalNode && attrs.originalNode.group - if (this.isHidden(group)) { + if (this.isHidden(group) || this.filterHidden.has(uid)) { res.hidden = true return res } @@ -259,6 +288,14 @@ export default class SigmaGraph extends React.Component { return res } + if (this.filterHidden.size) { + const [source, target] = this.graph.extremities(key) + if (this.filterHidden.has(source) || this.filterHidden.has(target)) { + res.hidden = true + return res + } + } + const rule = styleRules && styleRules[edge.predicate] if (rule && rule.color) { res.color = rule.color diff --git a/client/src/lib/graphFilter.js b/client/src/lib/graphFilter.js new file mode 100644 index 00000000..698e4181 --- /dev/null +++ b/client/src/lib/graphFilter.js @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Attribute operators offered by the filter panel. `needsValue: false` means +// the operator works on the attribute alone (e.g. "exists"). +export const FILTER_OPERATORS = [ + ['contains', 'contains', true], + ['eq', '=', true], + ['ne', '≠', true], + ['gt', '>', true], + ['lt', '<', true], + ['exists', 'exists', false], +] + +export const EMPTY_FILTER = { + degreeMin: '', + degreeMax: '', + attr: '', + op: 'contains', + value: '', +} + +// Whether a filter spec imposes any constraint at all. An inactive filter +// hides nothing. +export function filterActive(spec) { + if (!spec) { + return false + } + if (spec.degreeMin !== '' || spec.degreeMax !== '') { + return true + } + if (spec.attr) { + return spec.op === 'exists' || spec.value !== '' + } + return false +} + +// Returns the raw attribute values for a node as a flat array (an attribute +// may hold a scalar or a list). +export function attrValues(node, attr) { + const attrs = node && node.properties && node.properties.attrs + if (!attrs || !(attr in attrs)) { + return [] + } + const val = attrs[attr] + if (val == null) { + return [] + } + return Array.isArray(val) ? val : [val] +} + +function matchesAttribute(node, spec) { + const values = attrValues(node, spec.attr) + if (spec.op === 'exists') { + return values.length > 0 + } + if (spec.value === '') { + return true + } + + const needle = String(spec.value).toLowerCase() + const num = Number(spec.value) + + return values.some((raw) => { + switch (spec.op) { + case 'eq': + return String(raw).toLowerCase() === needle + case 'ne': + return String(raw).toLowerCase() !== needle + case 'gt': + return Number(raw) > num + case 'lt': + return Number(raw) < num + default: + return String(raw).toLowerCase().includes(needle) + } + }) +} + +/** + * Whether a node passes the active filter (true = keep/visible). `degree` is + * the node's connectivity in the rendered graph. + */ +export function nodeMatchesFilter(node, degree, spec) { + if (!filterActive(spec)) { + return true + } + + if (spec.degreeMin !== '' && degree < Number(spec.degreeMin)) { + return false + } + if (spec.degreeMax !== '' && degree > Number(spec.degreeMax)) { + return false + } + + if (spec.attr && (spec.op === 'exists' || spec.value !== '')) { + if (!matchesAttribute(node, spec)) { + return false + } + } + + return true +} + +// Sorted, de-duplicated attribute keys present across the node dataset, for +// populating the filter panel's attribute picker. +export function collectAttributeKeys(nodesDataset) { + const keys = new Set() + nodesDataset.forEach((node) => { + const attrs = node.properties && node.properties.attrs + if (attrs) { + Object.keys(attrs).forEach((k) => keys.add(k)) + } + }) + return Array.from(keys).sort((a, b) => a.localeCompare(b)) +} diff --git a/client/src/lib/graphFilter.test.js b/client/src/lib/graphFilter.test.js new file mode 100644 index 00000000..675fd8ff --- /dev/null +++ b/client/src/lib/graphFilter.test.js @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EMPTY_FILTER, + attrValues, + collectAttributeKeys, + filterActive, + nodeMatchesFilter, +} from './graphFilter' + +const node = (attrs) => ({ properties: { attrs, facets: {} } }) +const spec = (overrides) => ({ ...EMPTY_FILTER, ...overrides }) + +describe('filterActive', () => { + it('is false for the empty filter', () => { + expect(filterActive(EMPTY_FILTER)).toBe(false) + expect(filterActive(null)).toBe(false) + }) + + it('is true once a degree bound is set', () => { + expect(filterActive(spec({ degreeMin: '2' }))).toBe(true) + expect(filterActive(spec({ degreeMax: '5' }))).toBe(true) + }) + + it('requires a value for value-based operators', () => { + expect(filterActive(spec({ attr: 'age', op: 'gt', value: '' }))).toBe(false) + expect(filterActive(spec({ attr: 'age', op: 'gt', value: '3' }))).toBe(true) + }) + + it('is active for "exists" with no value', () => { + expect(filterActive(spec({ attr: 'age', op: 'exists' }))).toBe(true) + }) +}) + +describe('attrValues', () => { + it('wraps scalars and passes through arrays', () => { + expect(attrValues(node({ a: 5 }), 'a')).toEqual([5]) + expect(attrValues(node({ a: [1, 2] }), 'a')).toEqual([1, 2]) + }) + + it('returns empty for missing or null attributes', () => { + expect(attrValues(node({ a: 1 }), 'b')).toEqual([]) + expect(attrValues(node({ a: null }), 'a')).toEqual([]) + }) +}) + +describe('nodeMatchesFilter', () => { + it('keeps everything when the filter is inactive', () => { + expect(nodeMatchesFilter(node({ name: 'x' }), 0, EMPTY_FILTER)).toBe(true) + }) + + it('applies degree bounds', () => { + const f = spec({ degreeMin: '2', degreeMax: '4' }) + expect(nodeMatchesFilter(node({}), 1, f)).toBe(false) + expect(nodeMatchesFilter(node({}), 3, f)).toBe(true) + expect(nodeMatchesFilter(node({}), 5, f)).toBe(false) + }) + + it('matches contains case-insensitively', () => { + const f = spec({ attr: 'name', op: 'contains', value: 'ALI' }) + expect(nodeMatchesFilter(node({ name: 'Alice' }), 0, f)).toBe(true) + expect(nodeMatchesFilter(node({ name: 'Bob' }), 0, f)).toBe(false) + }) + + it('supports eq and ne', () => { + expect( + nodeMatchesFilter( + node({ k: 'v' }), + 0, + spec({ attr: 'k', op: 'eq', value: 'v' }), + ), + ).toBe(true) + expect( + nodeMatchesFilter( + node({ k: 'v' }), + 0, + spec({ attr: 'k', op: 'ne', value: 'v' }), + ), + ).toBe(false) + }) + + it('supports numeric gt and lt', () => { + expect( + nodeMatchesFilter( + node({ age: 30 }), + 0, + spec({ attr: 'age', op: 'gt', value: '18' }), + ), + ).toBe(true) + expect( + nodeMatchesFilter( + node({ age: 10 }), + 0, + spec({ attr: 'age', op: 'gt', value: '18' }), + ), + ).toBe(false) + expect( + nodeMatchesFilter( + node({ age: 10 }), + 0, + spec({ attr: 'age', op: 'lt', value: '18' }), + ), + ).toBe(true) + }) + + it('supports exists', () => { + const f = spec({ attr: 'email', op: 'exists' }) + expect(nodeMatchesFilter(node({ email: 'a@b.c' }), 0, f)).toBe(true) + expect(nodeMatchesFilter(node({ name: 'x' }), 0, f)).toBe(false) + }) + + it('matches if any value in a list satisfies the predicate', () => { + const f = spec({ attr: 'tags', op: 'eq', value: 'red' }) + expect(nodeMatchesFilter(node({ tags: ['blue', 'red'] }), 0, f)).toBe(true) + expect(nodeMatchesFilter(node({ tags: ['blue', 'green'] }), 0, f)).toBe( + false, + ) + }) + + it('combines degree and attribute constraints (AND)', () => { + const f = spec({ degreeMin: '2', attr: 'name', op: 'contains', value: 'a' }) + expect(nodeMatchesFilter(node({ name: 'alice' }), 3, f)).toBe(true) + expect(nodeMatchesFilter(node({ name: 'alice' }), 1, f)).toBe(false) + expect(nodeMatchesFilter(node({ name: 'bob' }), 3, f)).toBe(false) + }) +}) + +describe('collectAttributeKeys', () => { + it('returns sorted unique keys across nodes', () => { + const nodes = new Map([ + ['1', node({ name: 'a', age: 1 })], + ['2', node({ name: 'b', city: 'x' })], + ]) + expect(collectAttributeKeys(nodes)).toEqual(['age', 'city', 'name']) + }) +}) From be68a4dcdaab60791ad8667e552cacd05603a79d Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Mon, 15 Jun 2026 12:42:47 -0400 Subject: [PATCH 8/9] feat: timeline filtering and playback of the graph Detect ISO datetime attributes on nodes and, when the dataset spans a range, offer a timeline control: a clock toggle reveals a scrubber with play/pause that reveals nodes as their earliest timestamp passes. Nodes without a time stay as structural context; edges drop with either hidden endpoint. The cutoff is applied in the SigmaGraph reducers against a node _time attribute annotated in buildGraph, so scrubbing/playback is a cheap refresh. Co-Authored-By: Claude Fable 5 --- client/src/assets/css/Graph.scss | 50 ++++++++ client/src/components/GraphContainer.js | 108 ++++++++++++++++++ .../src/components/SigmaGraph/buildGraph.js | 4 + client/src/components/SigmaGraph/index.js | 22 +++- client/src/lib/graphTimeline.js | 63 ++++++++++ client/src/lib/graphTimeline.test.js | 71 ++++++++++++ 6 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 client/src/lib/graphTimeline.js create mode 100644 client/src/lib/graphTimeline.test.js diff --git a/client/src/assets/css/Graph.scss b/client/src/assets/css/Graph.scss index fdaad9ce..e218051e 100644 --- a/client/src/assets/css/Graph.scss +++ b/client/src/assets/css/Graph.scss @@ -207,3 +207,53 @@ } } } + +.graph-timeline { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + width: 60%; + max-width: 520px; + font-size: 12px; + color: #333; + background: rgba(255, 255, 255, 0.97); + border: 1px solid #ddd; + padding: 6px 12px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + z-index: 15; + + &__play { + flex: none; + width: 28px; + height: 28px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + color: #4e79a7; + cursor: pointer; + font-size: 12px; + line-height: 1; + + &:hover { + border-color: #4e79a7; + } + } + + &__slider { + flex: 1; + min-width: 0; + } + + &__label { + flex: none; + min-width: 92px; + text-align: right; + color: #555; + font-variant-numeric: tabular-nums; + } +} diff --git a/client/src/components/GraphContainer.js b/client/src/components/GraphContainer.js index f80cfdba..f199a1b5 100644 --- a/client/src/components/GraphContainer.js +++ b/client/src/components/GraphContainer.js @@ -20,6 +20,7 @@ import { nodeMatchesFilter, } from '../lib/graphFilter' import { loadStyleRules, saveStyleRules } from '../lib/graphStyles' +import { timelineRange } from '../lib/graphTimeline' import '../assets/css/Graph.scss' @@ -81,6 +82,11 @@ export default ({ const [filter, setFilter] = React.useState(EMPTY_FILTER) const [filterPanelOpen, setFilterPanelOpen] = React.useState(false) + // Timeline: scrub/animate the graph by node timestamps. + const [timeEnabled, setTimeEnabled] = React.useState(false) + const [timeCutoff, setTimeCutoff] = React.useState(null) + const [playing, setPlaying] = React.useState(false) + const handleStyleChange = (rules) => { setStyleRules(rules) saveStyleRules(rules) @@ -106,6 +112,12 @@ export default ({ [nodesDataset, graphUpdateHack], ) + const timeRange = React.useMemo( + () => timelineRange(nodesDataset), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nodesDataset, graphUpdateHack], + ) + // Node degree from the edge dataset, tolerating either uid-string or // resolved-object endpoints, used to preview how many nodes the filter hides. const hiddenCount = React.useMemo(() => { @@ -182,6 +194,60 @@ export default ({ setPathSource(null) } + const toggleTimeline = () => { + setPlaying(false) + setTimeEnabled((on) => { + const next = !on + if (next && timeRange.available) { + setTimeCutoff(timeRange.max) + } + return next + }) + } + + const togglePlay = () => { + if (!timeRange.available) return + if (!playing && (timeCutoff == null || timeCutoff >= timeRange.max)) { + setTimeCutoff(timeRange.min) + } + setPlaying((p) => !p) + } + + // Advance the scrubber while playing, ~7s end to end, stopping at the end. + React.useEffect(() => { + if (!playing || !timeRange.available) return undefined + const step = Math.max(1, (timeRange.max - timeRange.min) / 120) + const id = window.setInterval(() => { + setTimeCutoff((prev) => { + const base = prev == null ? timeRange.min : prev + const next = base + step + if (next >= timeRange.max) { + setPlaying(false) + return timeRange.max + } + return next + }) + }, 60) + return () => window.clearInterval(id) + }, [playing, timeRange]) + + // Reset the scrubber when the dataset's time span changes underneath it. + React.useEffect(() => { + if (timeEnabled && timeRange.available) { + setTimeCutoff((prev) => + prev == null || prev < timeRange.min || prev > timeRange.max + ? timeRange.max + : prev, + ) + } + }, [timeEnabled, timeRange]) + + const formatTime = (ms) => { + const date = new Date(ms) + const intraday = timeRange.max - timeRange.min < 2 * 24 * 3600 * 1000 + return intraday ? date.toLocaleString() : date.toLocaleDateString() + } + const activeNode = hoveredNode || selectedNode const activeEdge = !hoveredNode ? hoveredEdge || selectedEdge : null @@ -248,6 +314,7 @@ export default ({ filter={filter} pathNodes={pathResult && pathResult.nodes} pathEdges={pathResult && pathResult.edges} + timeCutoff={timeEnabled && timeRange.available ? timeCutoff : null} /> {/* Graph toolbar: search + controls */} @@ -349,6 +416,19 @@ export default ({ + {timeRange.available && ( + + )}
{stylePanelOpen && ( @@ -393,6 +473,34 @@ export default ({ )} + {timeEnabled && timeRange.available && ( +
+ + { + setPlaying(false) + setTimeCutoff(Number(e.target.value)) + }} + /> + + {formatTime(timeCutoff == null ? timeRange.max : timeCutoff)} + +
+ )} + {!remainingNodes ? null : (