From b87d58a6b4f760eebd5367cb49e3879a5ae313b0 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 08:31:19 -0500 Subject: [PATCH 1/3] refactor(web): retire d3 ForceGraph2D plugin and dead support code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified r3f engine now serves both 2D and 3D projections (ADR-702), so the d3 `react-force-graph-2d` plugin has no remaining role. Removed: - `web/src/explorers/ForceGraph2D/` — entire plugin - `react-force-graph-2d` dependency - `web/src/utils/graphTransform.ts` — only consumed by the d3 plugin and one unused `usePrefetchSubgraph` hook - `web/src/explorers/common/GraphSettingsPanel.tsx` — last consumer was the d3 plugin's ProfilePanel - `usePrefetchSubgraph` in `useGraphData.ts` — never called Cleaned the `force-2d` special-case out of `ExplorerView` (the legacy GraphSettingsPanel branch), the sidebar entry in `AppLayout`, the `/explore/2d` route in `App.tsx`, and the `'force-2d'` member of the `VisualizationType` union. The Zustand default falls back to `'force-3d'` until plugin consolidation lands. `/explore/2d-v2` and `/explore/3d` remain as the unified-engine routes during the consolidation step that follows. --- web/package-lock.json | 216 +- web/package.json | 1 - web/src/App.tsx | 1 - web/src/components/layout/AppLayout.tsx | 8 - .../explorers/ForceGraph2D/ForceGraph2D.tsx | 1808 ----------------- .../explorers/ForceGraph2D/ProfilePanel.tsx | 28 - web/src/explorers/ForceGraph2D/index.ts | 45 - web/src/explorers/ForceGraph2D/types.ts | 111 - .../explorers/common/GraphSettingsPanel.tsx | 424 ---- web/src/explorers/common/index.ts | 1 - web/src/explorers/index.ts | 2 - web/src/hooks/useGraphData.ts | 24 +- web/src/store/graphStore.ts | 2 +- web/src/types/explorer.ts | 1 - web/src/utils/graphTransform.ts | 257 --- web/src/views/ExplorerView.tsx | 24 +- 16 files changed, 12 insertions(+), 2941 deletions(-) delete mode 100644 web/src/explorers/ForceGraph2D/ForceGraph2D.tsx delete mode 100644 web/src/explorers/ForceGraph2D/ProfilePanel.tsx delete mode 100644 web/src/explorers/ForceGraph2D/index.ts delete mode 100644 web/src/explorers/ForceGraph2D/types.ts delete mode 100644 web/src/explorers/common/GraphSettingsPanel.tsx delete mode 100644 web/src/utils/graphTransform.ts diff --git a/web/package-lock.json b/web/package-lock.json index 6b4bf0013..b6c208863 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,7 +24,6 @@ "lucide-react": "^0.546.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-force-graph-2d": "^1.29.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.5", "reactflow": "^11.11.4", @@ -2928,12 +2927,6 @@ } } }, - "node_modules/@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3872,15 +3865,6 @@ "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==", "license": "BSD-3-Clause" }, - "node_modules/accessor-fn": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", - "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4116,16 +4100,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/bezier-js": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", - "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -4307,18 +4281,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-color-tracker": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", - "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", - "license": "MIT", - "dependencies": { - "tinycolor2": "^1.6.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -4674,12 +4636,6 @@ "node": ">=12" } }, - "node_modules/d3-binarytree": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", - "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", - "license": "MIT" - }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -4823,22 +4779,6 @@ "node": ">=12" } }, - "node_modules/d3-force-3d": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", - "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", - "license": "MIT", - "dependencies": { - "d3-binarytree": "1", - "d3-dispatch": "1 - 3", - "d3-octree": "1", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -4881,12 +4821,6 @@ "node": ">=12" } }, - "node_modules/d3-octree": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", - "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", - "license": "MIT" - }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -5723,20 +5657,6 @@ "dev": true, "license": "ISC" }, - "node_modules/float-tooltip": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", - "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", - "license": "MIT", - "dependencies": { - "d3-selection": "2 - 3", - "kapsule": "^1.16", - "preact": "10" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -5757,32 +5677,6 @@ } } }, - "node_modules/force-graph": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.0.tgz", - "integrity": "sha512-aTnihCmiMA0ItLJLCbrQYS9mzriopW24goFPgUnKAAmAlPogTSmFWqoBPMXzIfPb7bs04Hur5zEI4WYgLW3Sig==", - "license": "MIT", - "dependencies": { - "@tweenjs/tween.js": "18 - 25", - "accessor-fn": "1", - "bezier-js": "3 - 6", - "canvas-color-tracker": "^1.3", - "d3-array": "1 - 3", - "d3-drag": "2 - 3", - "d3-force-3d": "2 - 3", - "d3-scale": "1 - 4", - "d3-scale-chromatic": "1 - 3", - "d3-selection": "2 - 3", - "d3-zoom": "2 - 3", - "float-tooltip": "^1.7", - "index-array-by": "1", - "kapsule": "^1.16", - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6340,15 +6234,6 @@ "node": ">=8" } }, - "node_modules/index-array-by": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", - "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -6539,15 +6424,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jerrypick": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", - "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -6562,6 +6438,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -6701,18 +6578,6 @@ "node": ">=6" } }, - "node_modules/kapsule": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", - "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", - "license": "MIT", - "dependencies": { - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6782,12 +6647,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6805,18 +6664,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -7663,6 +7510,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8061,16 +7909,6 @@ "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", "license": "ISC" }, - "node_modules/preact": { - "version": "10.27.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", - "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8129,17 +7967,6 @@ "lie": "^3.0.2" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -8208,43 +8035,12 @@ "react": "^19.2.0" } }, - "node_modules/react-force-graph-2d": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.0.tgz", - "integrity": "sha512-Xv5IIk+hsZmB3F2ibja/t6j/b0/1T9dtFOQacTUoLpgzRHrO6wPu1GtQ2LfRqI/imgtaapnXUgQaE8g8enPo5w==", - "license": "MIT", - "dependencies": { - "force-graph": "^1.51", - "prop-types": "15", - "react-kapsule": "^2.5" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-kapsule": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", - "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", "license": "MIT", - "dependencies": { - "jerrypick": "^1.1.1" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "react": ">=16.13.1" - } + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -9244,12 +9040,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", diff --git a/web/package.json b/web/package.json index eeed154ca..33f09a730 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,6 @@ "lucide-react": "^0.546.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-force-graph-2d": "^1.29.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.9.5", "reactflow": "^11.11.4", diff --git a/web/src/App.tsx b/web/src/App.tsx index cfa157c11..861ba8d0d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -125,7 +125,6 @@ const AppContent: React.FC = () => { } /> {/* Explorers */} - } /> } /> } /> } /> diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx index e977bb518..79087f7c7 100644 --- a/web/src/components/layout/AppLayout.tsx +++ b/web/src/components/layout/AppLayout.tsx @@ -26,7 +26,6 @@ import { PencilLine, Settings, Shield, - Network, Boxes, Map, GitBranch, @@ -76,7 +75,6 @@ export const AppLayout: React.FC = ({ children }) => { const path = location.pathname; if (path === '/') return 'Home'; if (path === '/explore/2d-v2') return '2D Force Graph (V2)'; - if (path.startsWith('/explore/2d')) return '2D Force Graph'; if (path.startsWith('/explore/3d')) return '3D Force Graph'; if (path.startsWith('/explore/documents')) return 'Document Explorer'; if (path.startsWith('/blocks')) return 'Block Editor'; @@ -128,12 +126,6 @@ export const AppLayout: React.FC = ({ children }) => { {/* Explorers */} - navigate('/explore/2d')} - /> n.id === nodeId); - return { - x: node?.x || 0, - y: node?.y || 0, - }; -} - -/** - * Calculate edge midpoint position for info boxes - * Handles both straight and curved edges (quadratic Bezier) - */ -function calculateEdgeMidpoint( - link: RenderLink, - curveOffset: number, - draggedNodeId?: string, - draggedNodeX?: number, - draggedNodeY?: number -): { x: number; y: number } { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - // Get source position (use drag position if this is the dragged node) - const sourceX = sourceId === draggedNodeId && draggedNodeX !== undefined - ? draggedNodeX - : (typeof link.source === 'object' ? link.source.x || 0 : 0); - const sourceY = sourceId === draggedNodeId && draggedNodeY !== undefined - ? draggedNodeY - : (typeof link.source === 'object' ? link.source.y || 0 : 0); - - // Get target position (use drag position if this is the dragged node) - const targetX = targetId === draggedNodeId && draggedNodeX !== undefined - ? draggedNodeX - : (typeof link.target === 'object' ? link.target.x || 0 : 0); - const targetY = targetId === draggedNodeId && draggedNodeY !== undefined - ? draggedNodeY - : (typeof link.target === 'object' ? link.target.y || 0 : 0); - - let midX, midY; - - if (curveOffset === 0) { - // Straight line - simple midpoint - midX = (sourceX + targetX) / 2; - midY = (sourceY + targetY) / 2; - } else { - // Curved line - calculate point on quadratic Bezier curve at t=0.5 - const dx = targetX - sourceX; - const dy = targetY - sourceY; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 0.01) { - // Guard against zero or very small distance - midX = (sourceX + targetX) / 2; - midY = (sourceY + targetY) / 2; - } else { - // Calculate control point - const perpX = -dy / distance; - const perpY = dx / distance; - const controlX = (sourceX + targetX) / 2 + perpX * curveOffset; - const controlY = (sourceY + targetY) / 2 + perpY * curveOffset; - - // Evaluate quadratic Bezier at t=0.5 - const t = 0.5; - midX = (1 - t) * (1 - t) * sourceX + 2 * (1 - t) * t * controlX + t * t * targetX; - midY = (1 - t) * (1 - t) * sourceY + 2 * (1 - t) * t * controlY + t * t * targetY; - } - } - - return { x: midX, y: midY }; -} - -export const ForceGraph2D: React.FC< - ExplorerProps -> = ({ data, settings, onSettingsChange, onNodeClick, onSendToReports, className }) => { - const svgRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 1000, height: 800 }); - const [hoveredNode, setHoveredNode] = useState(null); - const [hoveredEdge, setHoveredEdge] = useState(null); - const [focusedNode, setFocusedNode] = useState(null); - const simulationRef = useRef | null>(null); - const zoomBehaviorRef = useRef | null>(null); - - // Get current theme for label colors (use appliedTheme which resolves 'system' to actual theme) - const { appliedTheme: theme } = useThemeStore(); - - // Get edge category filters from store - const { filters } = useGraphStore(); - - // Apply edge category filter to data - const filteredData = useMemo(() => { - return filterByEdgeCategory(data, filters.visibleEdgeCategories); - }, [data, filters.visibleEdgeCategories]); - - // Track zoom transform for info box positioning - const [zoomTransform, setZoomTransform] = useState({ x: 0, y: 0, k: 1 }); - - // Track active edge info boxes - interface EdgeInfo { - linkKey: string; - sourceId: string; - targetId: string; - type: string; - confidence: number; - category?: string; - x: number; - y: number; - } - const [activeEdgeInfos, setActiveEdgeInfos] = useState([]); - - // Track active node info boxes - interface NodeInfo { - nodeId: string; - label: string; - group: string; - degree: number; - x: number; - y: number; - } - const [activeNodeInfos, setActiveNodeInfos] = useState([]); - - // Imperative function to apply gold ring - can be called anytime - const applyGoldRing = useCallback((nodeId: string) => { - if (!svgRef.current || !settings.interaction.showOriginNode) return; - - const svg = d3.select(svgRef.current); - - // Remove from previous node (restore brighter stroke) - svg.selectAll('circle.origin-node') - .interrupt() - .attr('stroke', function() { - const d = d3.select(this).datum() as RenderNode; - const color = nodeColors.get(d.id) || d.color; - return d3.color(color)?.brighter(0.4).toString() || color; - }) - .attr('stroke-width', 2) - .attr('stroke-opacity', 1) - .classed('origin-node', false); - - // Add to target node - const targetCircle = svg.select(`circle[data-node-id="${nodeId}"]`); - - if (!targetCircle.empty()) { - targetCircle - .attr('stroke', '#FFD700') - .attr('stroke-width', 4) - .classed('origin-node', true); - - // Start pulsing animation - const pulse = () => { - targetCircle - .transition() - .duration(1000) - .attr('stroke-width', 6) - .attr('stroke-opacity', 0.6) - .transition() - .duration(1000) - .attr('stroke-width', 4) - .attr('stroke-opacity', 1) - .on('end', pulse); - }; - pulse(); - } - }, [settings.interaction.showOriginNode]); - - // Imperative function to apply blue ring for destination - const applyBlueRing = useCallback((nodeId: string) => { - if (!svgRef.current || !settings.interaction.showOriginNode) return; - - const svg = d3.select(svgRef.current); - - // Remove from previous node (restore brighter stroke) - svg.selectAll('circle.destination-node') - .interrupt() - .attr('stroke', function() { - const d = d3.select(this).datum() as RenderNode; - const color = nodeColors.get(d.id) || d.color; - return d3.color(color)?.brighter(0.4).toString() || color; - }) - .attr('stroke-width', 2) - .attr('stroke-opacity', 1) - .classed('destination-node', false); - - // Add to target node - const targetCircle = svg.select(`circle[data-node-id="${nodeId}"]`); - - if (!targetCircle.empty()) { - targetCircle - .attr('stroke', '#4169E1') // Royal Blue for destination - .attr('stroke-width', 4) - .classed('destination-node', true); - - // Start pulsing animation - const pulse = () => { - targetCircle - .transition() - .duration(1000) - .attr('stroke-width', 6) - .attr('stroke-opacity', 0.6) - .transition() - .duration(1000) - .attr('stroke-width', 4) - .attr('stroke-opacity', 1) - .on('end', pulse); - }; - pulse(); - } - }, [settings.interaction.showOriginNode]); - - // Unified context menu state (handles both node and background clicks) - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - nodeId: string | null; // null for background clicks - nodeLabel: string | null; // null for background clicks - } | null>(null); - - // Right-click drag tracking for pan behavior - const rightClickStartRef = useRef<{ x: number; y: number; time: number } | null>(null); - const [isRightClickDragging, setIsRightClickDragging] = useState(false); - - // Get navigation state and settings from store - const { originNodeId, setOriginNodeId, destinationNodeId, setDestinationNodeId, setFocusedNodeId, setGraphData, graphData } = useGraphStore(); - - // Calculate neighbors for highlighting (hover) - const neighbors = useMemo(() => { - if (!hoveredNode || !settings.interaction.highlightNeighbors) return new Set(); - return getNeighbors(hoveredNode, filteredData.links); - }, [hoveredNode, filteredData.links, settings.interaction.highlightNeighbors]); - - // Calculate neighbors for focus mode (stronger highlight) - const focusNeighbors = useMemo(() => { - if (!focusedNode || !settings.interaction.highlightNeighbors) return new Set(); - return getNeighbors(focusedNode, filteredData.links); - }, [focusedNode, filteredData.links, settings.interaction.highlightNeighbors]); - - // Centrality uses the unfiltered graph (it's a property of the node in - // the larger graph); ontology and degree use the visible subset. - const nodeColors = useMemo(() => { - const mode = settings.visual.nodeColorBy; - const useFull = mode === 'centrality'; - const sourceNodes = useFull ? data.nodes : filteredData.nodes; - const sourceLinks = useFull ? data.links : filteredData.links; - const nodeInputs = sourceNodes.map((n) => ({ id: n.id, fallbackColor: n.color })); - const edgeInputs = sourceLinks.map((l) => ({ - sourceId: typeof l.source === 'string' ? l.source : l.source.id, - targetId: typeof l.target === 'string' ? l.target : l.target.id, - })); - return computeNodeColors(nodeInputs, edgeInputs, mode); - }, [filteredData.nodes, filteredData.links, data.nodes, data.links, settings.visual.nodeColorBy]); - - // Calculate edge colors based on edgeColorBy setting - const linkColors = useMemo(() => { - const colors = new Map(); - - if (settings.visual.edgeColorBy === 'confidence') { - // Find min/max confidence values in the data for dynamic scaling - const confidenceValues = data.links.map(link => link.value || 0.5); - const minConfidence = Math.min(...confidenceValues); - const maxConfidence = Math.max(...confidenceValues); - - // Use actual data range, or fallback to [0, 1] if all values are the same - const domain = minConfidence === maxConfidence - ? [0, 1] - : [minConfidence, maxConfidence]; - - const colorScale = d3.scaleSequential(d3.interpolateTurbo).domain(domain); - - data.links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - const linkKey = `${sourceId}->${targetId}-${link.type}`; - const confidence = link.value || 0.5; - colors.set(linkKey, colorScale(confidence)); - }); - } else { - // Category or uniform coloring - data.links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - const linkKey = `${sourceId}->${targetId}-${link.type}`; - - if (settings.visual.edgeColorBy === 'category') { - // Color by category (default from transformForD3) - colors.set(linkKey, link.color); - } else if (settings.visual.edgeColorBy === 'uniform') { - // Uniform gray color - colors.set(linkKey, '#6b7280'); - } - }); - } - - return colors; - }, [filteredData.links, settings.visual.edgeColorBy]); - - // Calculate curve offsets for multiple edges between same nodes - // This ensures edges don't overlap and their labels are visible - const linkCurveOffsets = useMemo(() => { - const offsets = new Map(); - - // Group links by node pair (undirected) - const linkGroups = new Map(); - - data.links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - // Create a consistent key for the node pair (sorted to treat as undirected) - const pairKey = [sourceId, targetId].sort().join('->'); - - if (!linkGroups.has(pairKey)) { - linkGroups.set(pairKey, []); - } - linkGroups.get(pairKey)!.push(link); - }); - - // Assign curve offsets to links in groups with multiple edges - linkGroups.forEach(links => { - if (links.length > 1) { - // Multiple edges between same nodes - distribute them with curves - links.forEach((link, index) => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - const linkKey = `${sourceId}->${targetId}-${link.type}`; - - // Calculate offset: center around 0 and spread evenly - const totalLinks = links.length; - const offsetMultiplier = index - (totalLinks - 1) / 2; - const curveStrength = 30; // Base curve distance - - offsets.set(linkKey, offsetMultiplier * curveStrength); - }); - } else { - // Single edge - no curve needed (offset = 0) - const link = links[0]; - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - const linkKey = `${sourceId}->${targetId}-${link.type}`; - offsets.set(linkKey, 0); - } - }); - - return offsets; - }, [data.links]); - - // Initialize and update force simulation - useEffect(() => { - if (!svgRef.current || !data.nodes.length) return; - - const svg = d3.select(svgRef.current); - const width = dimensions.width; - const height = dimensions.height; - - // Auto-disable shadows for large graphs (performance protection) - const totalElements = data.nodes.length + data.links.length; - if (totalElements > 5000 && settings.visual.showShadows) { - console.warn(`⚠️ Graph has ${totalElements} elements. Auto-disabling shadows for performance. You can re-enable manually.`); - onSettingsChange?.({ - ...settings, - visual: { ...settings.visual, showShadows: false }, - }); - } - - // Clear previous content - svg.selectAll('*').remove(); - - // Define SVG filters for shadow effects - const defs = svg.append('defs'); - const shadowFilter = defs.append('filter') - .attr('id', 'drop-shadow') - .attr('x', '-50%') - .attr('y', '-50%') - .attr('width', '200%') - .attr('height', '200%'); - - shadowFilter.append('feGaussianBlur') - .attr('in', 'SourceAlpha') - .attr('stdDeviation', 2); - - shadowFilter.append('feOffset') - .attr('dx', 3.6) - .attr('dy', 3.6) - .attr('result', 'offsetblur'); - - shadowFilter.append('feComponentTransfer') - .append('feFuncA') - .attr('type', 'linear') - .attr('slope', 0.8); - - const feMerge = shadowFilter.append('feMerge'); - feMerge.append('feMergeNode'); - feMerge.append('feMergeNode') - .attr('in', 'SourceGraphic'); - - // Create container groups (order determines layering) - const g = svg.append('g').attr('class', 'graph-container'); - - // Create grid group INSIDE graph container so it transforms with the graph - const gridGroup = g.append('g').attr('class', 'grid-layer'); - - // Shadow layers (rendered below main elements) - const edgeShadowsGroup = g.append('g').attr('class', 'edge-shadows'); - const linksGroup = g.append('g').attr('class', 'links'); - const nodeShadowsGroup = g.append('g').attr('class', 'node-shadows'); - const nodesGroup = g.append('g').attr('class', 'nodes'); - - // Setup zoom behavior - if (settings.interaction.enableZoom || settings.interaction.enablePan) { - const zoom = d3 - .zoom() - .scaleExtent([0.1, 10]) - .filter((event) => { - // Allow zoom/pan with: - // - Left mouse button (button 0) - // - Right mouse button (button 2) - for right-click+drag pan - // - Mouse wheel (type 'wheel') - // - Touch events - return !event.ctrlKey && ( - event.type === 'wheel' || - event.type === 'touchstart' || - event.type === 'touchmove' || - event.button === 0 || - event.button === 2 - ); - }) - .on('zoom', (event) => { - g.attr('transform', event.transform); - - // Update zoom transform state for info box positioning - setZoomTransform({ - x: event.transform.x, - y: event.transform.y, - k: event.transform.k, - }); - }); - - // Store zoom behavior in ref for travel functions to use - zoomBehaviorRef.current = zoom; - - if (settings.interaction.enableZoom && settings.interaction.enablePan) { - svg.call(zoom); - } else if (settings.interaction.enableZoom) { - svg.call(zoom).on('mousedown.zoom', null); - } else if (settings.interaction.enablePan) { - svg.call(zoom).on('wheel.zoom', null); - } - } - - // Check if nodes already have positions (from merge/previous simulation) - const hasExistingPositions = filteredData.nodes.some(n => n.x !== undefined && n.y !== undefined); - - // Create force simulation - const simulation = d3 - .forceSimulation(filteredData.nodes) - .force( - 'link', - d3 - .forceLink(filteredData.links) - .id((d) => d.id) - .distance(settings.physics.linkDistance) - ) - .force('charge', d3.forceManyBody().strength(settings.physics.charge)) - .force('center', d3.forceCenter(width / 2, height / 2).strength(settings.physics.gravity)) - .force('collision', d3.forceCollide().radius((d) => ((d as RenderNode).size || 10) * settings.visual.nodeSize + 5)) - .velocityDecay(1 - settings.physics.friction); - - // Control initial simulation energy — lower warmth = less scatter on load - const warmth = settings.physics.warmth ?? 0.3; - if (hasExistingPositions) { - simulation.alpha(warmth).alphaDecay(0.05); - } else { - simulation.alpha(warmth); - } - - simulationRef.current = simulation; - - // Stop simulation if physics disabled - if (!settings.physics.enabled) { - simulation.stop(); - } - - // Draw edge shadows if enabled - // Edge shadows now handled by SVG filter on the edges themselves - - // Draw links as paths (supports curves for multiple edges) - const link = linksGroup - .selectAll('path') - .data(filteredData.links) - .join('path') - .attr('stroke', (d) => { - const sourceId = typeof d.source === 'string' ? d.source : d.source.id; - const targetId = typeof d.target === 'string' ? d.target : d.target.id; - const linkKey = `${sourceId}->${targetId}-${d.type}`; - return linkColors.get(linkKey) || d.color; - }) - .attr('stroke-width', (d) => (d.value || 1) * settings.visual.linkWidth) - .attr('stroke-opacity', 0.6) - .attr('fill', 'none') - .attr('marker-end', (d) => { - if (!settings.visual.showArrows) return ''; - // Use category-specific marker - const category = d.category || 'default'; - return `url(#arrowhead-${category})`; - }) - .attr('cursor', 'pointer') - .attr('data-link-key', (d) => { - const sourceId = typeof d.source === 'string' ? d.source : d.source.id; - const targetId = typeof d.target === 'string' ? d.target : d.target.id; - return `${sourceId}->${targetId}-${d.type}`; - }) - .style('filter', settings.visual.showShadows ? 'url(#drop-shadow)' : null) - .on('mouseenter', (_event, d) => { - const sourceId = typeof d.source === 'string' ? d.source : d.source.id; - const targetId = typeof d.target === 'string' ? d.target : d.target.id; - setHoveredEdge(`${sourceId}->${targetId}-${d.type}`); - }) - .on('mouseleave', () => { - setHoveredEdge(null); - }) - .on('click', (event, d) => { - event.stopPropagation(); // Prevent triggering background click - const sourceId = typeof d.source === 'string' ? d.source : d.source.id; - const targetId = typeof d.target === 'string' ? d.target : d.target.id; - const linkKey = `${sourceId}->${targetId}-${d.type}`; - - // Use functional setState to ensure we have the latest state - setActiveEdgeInfos(prev => { - const exists = prev.some(info => info.linkKey === linkKey); - if (exists) return prev; // Don't create duplicate - - // Calculate edge midpoint (will be updated during simulation) - const sourceX = typeof d.source === 'object' ? d.source.x || 0 : 0; - const sourceY = typeof d.source === 'object' ? d.source.y || 0 : 0; - const targetX = typeof d.target === 'object' ? d.target.x || 0 : 0; - const targetY = typeof d.target === 'object' ? d.target.y || 0 : 0; - - const curveOffset = linkCurveOffsets.get(linkKey) || 0; - let midX, midY; - - // Check if this is a self-loop - const isSelfLoop = sourceId === targetId; - - if (isSelfLoop) { - // Self-loop: position at apex of hairpin curve - const nodeRadius = ((d.source as any).size || 10) * settings.visual.nodeSize; - const baseLoopSize = nodeRadius * 3; - const loopSize = baseLoopSize + Math.abs(curveOffset); - - const startAngle = curveOffset * 0.3; - const endAngle = startAngle + Math.PI / 6; - const midAngle = (startAngle + endAngle) / 2; - - // Recreate cubic Bezier curve - const loopStartX = sourceX + nodeRadius * Math.cos(startAngle); - const loopStartY = sourceY + nodeRadius * Math.sin(startAngle); - const control1X = sourceX + loopSize * Math.cos(midAngle - 0.3); - const control1Y = sourceY + loopSize * Math.sin(midAngle - 0.3); - const control2X = sourceX + loopSize * Math.cos(midAngle + 0.3); - const control2Y = sourceY + loopSize * Math.sin(midAngle + 0.3); - const loopEndX = sourceX + nodeRadius * Math.cos(endAngle); - const loopEndY = sourceY + nodeRadius * Math.sin(endAngle); - - // Calculate position at t=0.5 (apex) - const t = 0.5; - const mt = 1 - t; - midX = mt * mt * mt * loopStartX + - 3 * mt * mt * t * control1X + - 3 * mt * t * t * control2X + - t * t * t * loopEndX; - midY = mt * mt * mt * loopStartY + - 3 * mt * mt * t * control1Y + - 3 * mt * t * t * control2Y + - t * t * t * loopEndY; - } else if (curveOffset === 0) { - midX = (sourceX + targetX) / 2; - midY = (sourceY + targetY) / 2; - } else { - const dx = targetX - sourceX; - const dy = targetY - sourceY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Guard against zero distance - if (distance < 0.01) { - midX = (sourceX + targetX) / 2; - midY = (sourceY + targetY) / 2; - } else { - const perpX = -dy / distance; - const perpY = dx / distance; - const controlX = (sourceX + targetX) / 2 + perpX * curveOffset; - const controlY = (sourceY + targetY) / 2 + perpY * curveOffset; - const t = 0.5; - midX = (1 - t) * (1 - t) * sourceX + 2 * (1 - t) * t * controlX + t * t * targetX; - midY = (1 - t) * (1 - t) * sourceY + 2 * (1 - t) * t * controlY + t * t * targetY; - } - } - - // Create new edge info - const newInfo: EdgeInfo = { - linkKey, - sourceId, - targetId, - type: d.type, - confidence: d.value || 1.0, - category: d.category, // Vocabulary category (derivation, modification, etc.) - x: midX, - y: midY, - }; - - return [...prev, newInfo]; - }); - }); - - // Add arrow marker definitions - one per category color - if (settings.visual.showArrows) { - const defs = svg.append('defs'); - - // Create markers for each category (uses shared config) - Object.entries(categoryColors).forEach(([category, color]) => { - defs - .append('marker') - .attr('id', `arrowhead-${category}`) - .attr('viewBox', '-0 -5 10 10') - .attr('refX', 8) // Position arrow tip at node boundary - .attr('refY', 0) - .attr('orient', 'auto') - .attr('markerWidth', 4) - .attr('markerHeight', 4) - .append('path') - .attr('d', 'M 0,-5 L 10,0 L 0,5') - .attr('fill', color); - }); - } - - // Add edge labels showing relationship types - const edgeLabels = linksGroup - .selectAll('text') - .data(filteredData.links) - .join('text') - .text((d) => d.type) - .attr('font-family', LABEL_FONTS.family) - .attr('font-size', settings.visual?.edgeLabelSize ?? 9) - .attr('font-weight', LABEL_STYLE_2D.edge.fontWeight) - .attr('fill', (d) => { - // Use unified color transformation - const baseColor = d.color || '#6b7280'; - const colors = ColorTransform.getLabelColors(baseColor, 'edge', theme); - return colors.fill; - }) - .attr('stroke', (d) => { - // Use unified color transformation - const baseColor = d.color || '#6b7280'; - const colors = ColorTransform.getLabelColors(baseColor, 'edge', theme); - return colors.stroke; - }) - .attr('stroke-width', LABEL_STYLE_2D.edge.strokeWidth) - .attr('paint-order', LABEL_STYLE_2D.edge.paintOrder) - .attr('text-anchor', 'middle') - .attr('pointer-events', 'none') - .style('user-select', 'none'); - - // Render node highlights FIRST (before circles) if shadows enabled, so circles are on top - if (settings.visual.showShadows) { - // Node shadows now handled by SVG filter on the nodes themselves - - // Node highlights (reflection and shade arcs) - MUST be created before circles - nodesGroup - .selectAll('.highlight-arc') - .data(data.nodes.flatMap(d => [ - { node: d, arcType: 'reflection' }, - { node: d, arcType: 'shade' } - ])) - .join('path') - .attr('class', 'highlight-arc') - .attr('d', (d: any) => { - const nodeRadius = ((d.node.size || 10) * settings.visual.nodeSize); - const arcRadius = nodeRadius * 0.8; // 80% of node diameter - - // Arc angles in degrees (0° = right/3 o'clock) - const isReflection = d.arcType === 'reflection'; - const startAngle = isReflection ? 290 : 110; // degrees - const endAngle = isReflection ? 340 : 160; // degrees - - // Convert to radians - const startRad = (startAngle - 90) * (Math.PI / 180); - const endRad = (endAngle - 90) * (Math.PI / 180); - - // Calculate arc path (80% of node radius) - const x1 = arcRadius * Math.cos(startRad); - const y1 = arcRadius * Math.sin(startRad); - const x2 = arcRadius * Math.cos(endRad); - const y2 = arcRadius * Math.sin(endRad); - - // Large arc flag: 0 for arcs <= 180°, 1 for arcs > 180° - const largeArcFlag = 0; - - return `M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArcFlag} 1 ${x2} ${y2}`; - }) - .attr('fill', 'none') - .attr('stroke', (d: any) => { - const baseColor = nodeColors.get(d.node.id) || d.node.color; - const color = d3.color(baseColor); - if (!color) return baseColor; - - if (d.arcType === 'reflection') { - // Increase luminance for reflection (nearly white) - return color.brighter(2.5).toString(); - } else { - // Decrease luminance for shade (darker) - return color.darker(1.5).toString(); - } - }) - .attr('stroke-width', 3) - .attr('stroke-linecap', 'round') - .attr('pointer-events', 'none'); - } - - // Draw nodes (AFTER highlights so they're on top and receive events) - const node = nodesGroup - .selectAll('circle') - .data(filteredData.nodes) - .join('circle') - .attr('r', (d) => (d.size || 10) * settings.visual.nodeSize) - .attr('fill', (d) => nodeColors.get(d.id) || d.color) - .attr('stroke', (d) => { - const color = nodeColors.get(d.id) || d.color; - return d3.color(color)?.brighter(0.4).toString() || color; - }) - .attr('stroke-width', 2) - .attr('cursor', 'pointer') - .attr('data-node-id', (d) => d.id) // Add ID for selection - .style('filter', settings.visual.showShadows ? 'url(#drop-shadow)' : null) - .on('click', (event, d) => { - event.stopPropagation(); - setContextMenu(null); // Close any open context menu - - // Left click: Immediately show info box - // Use functional setState to ensure we have the latest state - setActiveNodeInfos(prev => { - const exists = prev.some(info => info.nodeId === d.id); - if (exists) return prev; // Don't create duplicate - - // Calculate degree (number of connections) - const degree = data.links.filter(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - return sourceId === d.id || targetId === d.id; - }).length; - - // Create new node info - const newInfo: NodeInfo = { - nodeId: d.id, - label: d.label, - group: d.group || 'Unknown', - degree, - x: d.x || 0, - y: d.y || 0, - }; - - return [...prev, newInfo]; - }); - }) - .on('contextmenu', (event, d) => { - // Right-click: Show context menu - event.preventDefault(); - event.stopPropagation(); // Prevent canvas context menu from showing - setContextMenu({ - x: event.clientX, - y: event.clientY, - nodeId: d.id, - nodeLabel: d.label, - }); - }) - .on('mouseenter', (_event, d) => { - setHoveredNode(d.id); - }) - .on('mouseleave', () => { - setHoveredNode(null); - }); - - // Add labels if enabled - let labels: d3.Selection | null = null; - if (settings.visual.showLabels) { - labels = nodesGroup - .selectAll('text') - .data(filteredData.nodes) - .join('text') - .text((d) => d.label) - .attr('font-family', LABEL_FONTS.family) - .attr('font-size', settings.visual?.nodeLabelSize ?? 12) - .attr('font-weight', LABEL_STYLE_2D.node.fontWeight) - .attr('fill', (d) => { - // Use unified color transformation - const baseColor = d.color || '#6b7280'; - const colors = ColorTransform.getLabelColors(baseColor, 'node', theme); - return colors.fill; - }) - .attr('stroke', (d) => { - // Use unified color transformation - const baseColor = d.color || '#6b7280'; - const colors = ColorTransform.getLabelColors(baseColor, 'node', theme); - return colors.stroke; - }) - .attr('stroke-width', LABEL_STYLE_2D.node.strokeWidth) - .attr('paint-order', LABEL_STYLE_2D.node.paintOrder) - .attr('text-anchor', 'middle') - .attr('pointer-events', 'none') - .style('user-select', 'none'); - } - - // Enable dragging if configured - if (settings.interaction.enableDrag) { - const drag = d3 - .drag() - .on('start', (event, d) => { - if (!event.active && settings.physics.enabled) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; - }) - .on('drag', (event, d) => { - d.fx = event.x; - d.fy = event.y; - - // Update node info box position immediately during drag - setActiveNodeInfos(prevInfos => - prevInfos.map(info => { - const { x, y } = calculateNodePosition(info.nodeId, data.nodes, d.id, event.x, event.y); - return { ...info, x, y }; - }) - ); - - // Update edge info boxes for edges connected to this node - if (activeEdgeInfos.length > 0) { - setActiveEdgeInfos(prevInfos => - prevInfos.map(info => { - // Find the corresponding link - const link = data.links.find(l => { - const sourceId = typeof l.source === 'string' ? l.source : l.source.id; - const targetId = typeof l.target === 'string' ? l.target : l.target.id; - return `${sourceId}->${targetId}-${l.type}` === info.linkKey; - }); - - if (!link) return info; - - // Check if this edge is connected to the dragged node - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - if (sourceId !== d.id && targetId !== d.id) return info; // Not connected - - // Recalculate edge midpoint with updated node position - const curveOffset = linkCurveOffsets.get(info.linkKey) || 0; - const { x: midX, y: midY } = calculateEdgeMidpoint(link, curveOffset, d.id, event.x, event.y); - - return { ...info, x: midX, y: midY }; - }) - ); - } - }) - .on('end', (event, _d) => { - if (!event.active && settings.physics.enabled) simulation.alphaTarget(0); - // Keep node fixed after dragging - // To unfix: _d.fx = null; _d.fy = null; - }); - - node.call(drag); - } - - // Update positions on simulation tick - simulation.on('tick', () => { - // Update curved paths for links - link.attr('d', (d) => { - const sourceNode = typeof d.source === 'object' ? d.source : null; - const targetNode = typeof d.target === 'object' ? d.target : null; - - const sourceX = sourceNode?.x || 0; - const sourceY = sourceNode?.y || 0; - const targetX = targetNode?.x || 0; - const targetY = targetNode?.y || 0; - - // Get target node radius (account for node size + stroke width) - const targetRadius = targetNode ? ((targetNode.size || 10) * settings.visual.nodeSize) + 2 : 10; - - // Get curve offset for this link - const sourceId = typeof d.source === 'string' ? d.source : d.source.id; - const targetId = typeof d.target === 'string' ? d.target : d.target.id; - const linkKey = `${sourceId}->${targetId}-${d.type}`; - const curveOffset = linkCurveOffsets.get(linkKey) || 0; - - // Check if this is a self-loop (edge connects to itself) - const isSelfLoop = sourceId === targetId; - - if (isSelfLoop) { - // Self-loop: create hairpin curve using cubic Bezier - const nodeRadius = sourceNode ? ((sourceNode.size || 10) * settings.visual.nodeSize) : 10; - - // Loop size increases with curve offset (for multiple self-loops) - const baseLoopSize = nodeRadius * 3; // Minimum loop size - const loopSize = baseLoopSize + Math.abs(curveOffset); - - // Create start and end points on node boundary (30 degrees apart) - const startAngle = curveOffset * 0.3; // Rotate based on offset (spreads multiple loops) - const endAngle = startAngle + Math.PI / 6; // 30 degrees apart - const midAngle = (startAngle + endAngle) / 2; - - const loopStartX = sourceX + nodeRadius * Math.cos(startAngle); - const loopStartY = sourceY + nodeRadius * Math.sin(startAngle); - const loopEndX = sourceX + nodeRadius * Math.cos(endAngle); - const loopEndY = sourceY + nodeRadius * Math.sin(endAngle); - - // Control points push curve outward (hairpin shape) - const control1X = sourceX + loopSize * Math.cos(midAngle - 0.3); - const control1Y = sourceY + loopSize * Math.sin(midAngle - 0.3); - const control2X = sourceX + loopSize * Math.cos(midAngle + 0.3); - const control2Y = sourceY + loopSize * Math.sin(midAngle + 0.3); - - // SVG cubic Bezier path: M start C control1 control2 end - return `M ${loopStartX},${loopStartY} C ${control1X},${control1Y} ${control2X},${control2Y} ${loopEndX},${loopEndY}`; - } - - // Calculate direction and distance - const dx = targetX - sourceX; - const dy = targetY - sourceY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Guard against zero or very small distance - if (distance < 0.01) { - return `M ${sourceX},${sourceY} L ${targetX},${targetY}`; - } - - if (curveOffset === 0) { - // Straight line - shorten to stop at target node boundary + 1 unit gap - const unitX = dx / distance; - const unitY = dy / distance; - const adjustedTargetX = targetX - unitX * (targetRadius + 1); - const adjustedTargetY = targetY - unitY * (targetRadius + 1); - return `M ${sourceX},${sourceY} L ${adjustedTargetX},${adjustedTargetY}`; - } else { - // Quadratic curve for multiple edges - // Perpendicular unit vector - const perpX = -dy / distance; - const perpY = dx / distance; - - // Control point at midpoint + perpendicular offset - const midX = (sourceX + targetX) / 2; - const midY = (sourceY + targetY) / 2; - const controlX = midX + perpX * curveOffset; - const controlY = midY + perpY * curveOffset; - - // Calculate tangent at curve endpoint (t=1) - // For quadratic Bezier, tangent at t=1 is in direction from control point to target - const tangentX = targetX - controlX; - const tangentY = targetY - controlY; - const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY); - - // Guard against zero-length tangent - if (tangentLength < 0.01) { - return `M ${sourceX},${sourceY} Q ${controlX},${controlY} ${targetX},${targetY}`; - } - - // Normalize tangent and shorten curve to stop at target node boundary + 1 unit gap - const tangentUnitX = tangentX / tangentLength; - const tangentUnitY = tangentY / tangentLength; - const adjustedTargetX = targetX - tangentUnitX * (targetRadius + 1); - const adjustedTargetY = targetY - tangentUnitY * (targetRadius + 1); - - return `M ${sourceX},${sourceY} Q ${controlX},${controlY} ${adjustedTargetX},${adjustedTargetY}`; - } - }); - - node.attr('cx', (d) => d.x || 0).attr('cy', (d) => d.y || 0); - - // Update edge label positions and rotation to align with edges - edgeLabels.attr('transform', (d) => { - const sourceNode = typeof d.source === 'object' ? d.source : null; - const targetNode = typeof d.target === 'object' ? d.target : null; - - const sourceX = sourceNode?.x || 0; - const sourceY = sourceNode?.y || 0; - const targetX = targetNode?.x || 0; - const targetY = targetNode?.y || 0; - - // Get curve offset for label positioning - const sourceId = typeof d.source === 'string' ? d.source : d.source.id; - const targetId = typeof d.target === 'string' ? d.target : d.target.id; - const linkKey = `${sourceId}->${targetId}-${d.type}`; - const curveOffset = linkCurveOffsets.get(linkKey) || 0; - - // Check if this is a self-loop - const isSelfLoop = sourceId === targetId; - - let midX, midY, angle; - - if (isSelfLoop) { - // Self-loop: position label at apex of hairpin curve - const nodeRadius = sourceNode ? ((sourceNode.size || 10) * settings.visual.nodeSize) : 10; - const baseLoopSize = nodeRadius * 3; - const loopSize = baseLoopSize + Math.abs(curveOffset); - - const startAngle = curveOffset * 0.3; - const endAngle = startAngle + Math.PI / 6; - const midAngle = (startAngle + endAngle) / 2; - - // Recreate cubic Bezier curve - const loopStartX = sourceX + nodeRadius * Math.cos(startAngle); - const loopStartY = sourceY + nodeRadius * Math.sin(startAngle); - const loopEndX = sourceX + nodeRadius * Math.cos(endAngle); - const loopEndY = sourceY + nodeRadius * Math.sin(endAngle); - const control1X = sourceX + loopSize * Math.cos(midAngle - 0.3); - const control1Y = sourceY + loopSize * Math.sin(midAngle - 0.3); - const control2X = sourceX + loopSize * Math.cos(midAngle + 0.3); - const control2Y = sourceY + loopSize * Math.sin(midAngle + 0.3); - - // Calculate position at t=0.5 (apex) on cubic Bezier - const t = 0.5; - const mt = 1 - t; - midX = mt * mt * mt * loopStartX + - 3 * mt * mt * t * control1X + - 3 * mt * t * t * control2X + - t * t * t * loopEndX; - midY = mt * mt * mt * loopStartY + - 3 * mt * mt * t * control1Y + - 3 * mt * t * t * control2Y + - t * t * t * loopEndY; - - // Calculate tangent at t=0.5 for cubic Bezier - const tangentX = 3 * mt * mt * (control1X - loopStartX) + - 6 * mt * t * (control2X - control1X) + - 3 * t * t * (loopEndX - control2X); - const tangentY = 3 * mt * mt * (control1Y - loopStartY) + - 6 * mt * t * (control2Y - control1Y) + - 3 * t * t * (loopEndY - control2Y); - angle = Math.atan2(tangentY, tangentX) * (180 / Math.PI); - } else if (curveOffset === 0) { - // Straight line - position at midpoint - midX = (sourceX + targetX) / 2; - midY = (sourceY + targetY) / 2; - angle = Math.atan2(targetY - sourceY, targetX - sourceX) * (180 / Math.PI); - } else { - // Curved line - position at curve midpoint (on the quadratic curve) - const dx = targetX - sourceX; - const dy = targetY - sourceY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // Guard against zero or very small distance - if (distance < 0.01) { - // Fallback to straight line positioning - midX = (sourceX + targetX) / 2; - midY = (sourceY + targetY) / 2; - angle = 0; - } else { - // Perpendicular unit vector - const perpX = -dy / distance; - const perpY = dx / distance; - - // Control point - const controlX = (sourceX + targetX) / 2 + perpX * curveOffset; - const controlY = (sourceY + targetY) / 2 + perpY * curveOffset; - - // Point on quadratic curve at t=0.5 (midpoint) - const t = 0.5; - midX = (1 - t) * (1 - t) * sourceX + 2 * (1 - t) * t * controlX + t * t * targetX; - midY = (1 - t) * (1 - t) * sourceY + 2 * (1 - t) * t * controlY + t * t * targetY; - - // Calculate tangent angle at midpoint - const tangentX = 2 * (1 - t) * (controlX - sourceX) + 2 * t * (targetX - controlX); - const tangentY = 2 * (1 - t) * (controlY - sourceY) + 2 * t * (targetY - controlY); - angle = Math.atan2(tangentY, tangentX) * (180 / Math.PI); - } - } - - // Keep text readable (don't flip upside down) - if (angle > 90 || angle < -90) { - angle += 180; - } - - return `translate(${midX},${midY}) rotate(${angle})`; - }); - - // Update node label positions to follow nodes (centered below node) - if (labels) { - labels - .attr('x', (d) => d.x || 0) - .attr('y', (d) => (d.y || 0) + (d.size || 10) * settings.visual.nodeSize + 14); - } - - // Update highlight positions if enabled (shadows handled by SVG filter) - if (settings.visual.showShadows) { - // Update node highlight arc positions - nodesGroup.selectAll('.highlight-arc') - .attr('transform', (d: any) => `translate(${d.node.x || 0}, ${d.node.y || 0})`); - } - - // Update info box positions to follow edges - if (activeEdgeInfos.length > 0) { - setActiveEdgeInfos(prevInfos => - prevInfos.map(info => { - // Find the corresponding link - const link = data.links.find(l => { - const sourceId = typeof l.source === 'string' ? l.source : l.source.id; - const targetId = typeof l.target === 'string' ? l.target : l.target.id; - return `${sourceId}->${targetId}-${l.type}` === info.linkKey; - }); - - if (!link) return info; // Link not found, keep old position - - const curveOffset = linkCurveOffsets.get(info.linkKey) || 0; - const { x: midX, y: midY } = calculateEdgeMidpoint(link, curveOffset); - - return { ...info, x: midX, y: midY }; - }) - ); - } - - // Update info box positions to follow nodes - if (activeNodeInfos.length > 0) { - setActiveNodeInfos(prevInfos => - prevInfos.map(info => { - const { x, y } = calculateNodePosition(info.nodeId, data.nodes); - return { ...info, x, y }; - }) - ); - } - }); - - return () => { - simulation.stop(); - }; - }, [filteredData, settings, dimensions, onNodeClick, nodeColors, linkColors, linkCurveOffsets, theme]); - - // Helper function to render grid in graph coordinates - const renderGrid = useCallback(() => { - if (!svgRef.current || !settings.visual.showGrid) return; - - const svg = d3.select(svgRef.current); - const g = svg.select('.graph-container'); - const gridGroup = g.select('.grid-layer'); - - if (gridGroup.empty()) return; - - // Clear existing grid - gridGroup.selectAll('*').remove(); - - // Get canvas background color and derive grid colors - const canvasColor = d3.color( - window.getComputedStyle(svgRef.current).backgroundColor || '#ffffff' - ); - const mainGridColor = canvasColor ? canvasColor.brighter(2.0).toString() : '#d0d0d0'; - const subGridColor = canvasColor ? canvasColor.brighter(1.0).toString() : '#e8e8e8'; - - // Grid spacing in graph coordinates - const mainGridSize = 100; - const subGridSize = mainGridSize / 2; - - // Render a large grid centered at origin (will transform with zoom/pan) - const gridExtent = 5000; // Large enough to cover any reasonable zoom/pan - - // Draw subdivision grid - for (let x = -gridExtent; x <= gridExtent; x += subGridSize) { - if (x % mainGridSize !== 0) { - gridGroup - .append('line') - .attr('class', 'sub-grid-line') - .attr('x1', x) - .attr('y1', -gridExtent) - .attr('x2', x) - .attr('y2', gridExtent) - .attr('stroke', subGridColor) - .attr('stroke-width', 1) - .attr('opacity', 0.6); - } - } - - for (let y = -gridExtent; y <= gridExtent; y += subGridSize) { - if (y % mainGridSize !== 0) { - gridGroup - .append('line') - .attr('class', 'sub-grid-line') - .attr('x1', -gridExtent) - .attr('y1', y) - .attr('x2', gridExtent) - .attr('y2', y) - .attr('stroke', subGridColor) - .attr('stroke-width', 1) - .attr('opacity', 0.6); - } - } - - // Draw main grid - for (let x = -gridExtent; x <= gridExtent; x += mainGridSize) { - gridGroup - .append('line') - .attr('class', 'main-grid-line') - .attr('x1', x) - .attr('y1', -gridExtent) - .attr('x2', x) - .attr('y2', gridExtent) - .attr('stroke', mainGridColor) - .attr('stroke-width', 1) - .attr('opacity', 0.8); - } - - for (let y = -gridExtent; y <= gridExtent; y += mainGridSize) { - gridGroup - .append('line') - .attr('class', 'main-grid-line') - .attr('x1', -gridExtent) - .attr('y1', y) - .attr('x2', gridExtent) - .attr('y2', y) - .attr('stroke', mainGridColor) - .attr('stroke-width', 1) - .attr('opacity', 0.8); - } - }, [settings.visual.showGrid]); - - // Render grid when SVG structure is rebuilt or visibility changes - useEffect(() => { - // Small delay to ensure grid-layer is created - const timer = setTimeout(() => renderGrid(), 0); - return () => clearTimeout(timer); - }, [renderGrid, data, settings, dimensions]); - - // Update highlighting based on focus and hover - useEffect(() => { - if (!svgRef.current) return; - - const svg = d3.select(svgRef.current); - - // Node highlighting (focus takes priority over hover) - svg.selectAll('circle').attr('opacity', (d) => { - if (!d) return 1; - - // Focus mode (stronger fade) - if (focusedNode) { - if (d.id === focusedNode) return 1; - if (focusNeighbors.has(d.id)) return 1; - return 0.05; // Much stronger fade for focus - } - - // Hover mode (lighter fade) - if (hoveredNode) { - if (d.id === hoveredNode) return 1; - if (neighbors.has(d.id)) return 1; - return 0.2; // Lighter fade for hover - } - - return 1; // No focus or hover - }); - - // Edge highlighting (paths not lines) - focus takes priority - svg.selectAll('path').each(function(link) { - const path = d3.select(this); - const linkKey = path.attr('data-link-key'); - - // Guard against undefined link data during graph updates - if (!link) { - return; - } - - const sourceId = typeof link.source === 'string' ? link.source : link.source?.id; - const targetId = typeof link.target === 'string' ? link.target : link.target?.id; - - if (hoveredEdge) { - // Edge hover mode - if (linkKey === hoveredEdge) { - path.attr('stroke-opacity', 1).attr('stroke-width', ((link.value || 1) * settings.visual.linkWidth) * 2); - } else { - path.attr('stroke-opacity', 0.2).attr('stroke-width', (link.value || 1) * settings.visual.linkWidth); - } - } else if (focusedNode) { - // Focus mode (stronger fade) - if (!sourceId || !targetId) { - path.attr('stroke-opacity', 0.6); - } else if (sourceId === focusedNode || targetId === focusedNode) { - path.attr('stroke-opacity', 1); - } else { - path.attr('stroke-opacity', 0.02); // Much stronger fade for focus - } - path.attr('stroke-width', (link.value || 1) * settings.visual.linkWidth); - } else if (hoveredNode) { - // Node hover mode (lighter fade) - if (!sourceId || !targetId) { - path.attr('stroke-opacity', 0.6); - } else if (sourceId === hoveredNode || targetId === hoveredNode) { - path.attr('stroke-opacity', 1); - } else { - path.attr('stroke-opacity', 0.1); // Lighter fade for hover - } - path.attr('stroke-width', (link.value || 1) * settings.visual.linkWidth); - } else { - // No focus or hover - path.attr('stroke-opacity', 0.6).attr('stroke-width', (link.value || 1) * settings.visual.linkWidth); - } - }); - }, [focusedNode, hoveredNode, hoveredEdge, neighbors, focusNeighbors, settings.visual.linkWidth]); - - // "You Are Here" highlighting for origin node - async update after DOM ready - useEffect(() => { - if (!originNodeId || !settings.interaction.showOriginNode) return; - - // Wait for next frame to ensure DOM is fully rendered - const rafId = requestAnimationFrame(() => { - // Double-raf for extra safety (ensures layout is complete) - requestAnimationFrame(() => { - applyGoldRing(originNodeId); - }); - }); - - return () => { - cancelAnimationFrame(rafId); - // Cleanup: remove gold ring (restore brighter stroke) - if (svgRef.current) { - d3.select(svgRef.current) - .selectAll('circle.origin-node') - .interrupt() - .attr('stroke', function() { - const d = d3.select(this).datum() as RenderNode; - const color = nodeColors.get(d.id) || d.color; - return d3.color(color)?.brighter(0.4).toString() || color; - }) - .attr('stroke-width', 2) - .attr('stroke-opacity', 1) - .classed('origin-node', false); - } - }; - }, [originNodeId, settings.interaction.showOriginNode, data, applyGoldRing]); - - // "Destination" highlighting - async update after DOM ready - useEffect(() => { - if (!destinationNodeId || !settings.interaction.showOriginNode) return; - - // Wait for next frame to ensure DOM is fully rendered - const rafId = requestAnimationFrame(() => { - // Double-raf for extra safety (ensures layout is complete) - requestAnimationFrame(() => { - applyBlueRing(destinationNodeId); - }); - }); - - return () => { - cancelAnimationFrame(rafId); - // Cleanup: remove blue ring (restore brighter stroke) - if (svgRef.current) { - d3.select(svgRef.current) - .selectAll('circle.destination-node') - .interrupt() - .attr('stroke', function() { - const d = d3.select(this).datum() as RenderNode; - const color = nodeColors.get(d.id) || d.color; - return d3.color(color)?.brighter(0.4).toString() || color; - }) - .attr('stroke-width', 2) - .attr('stroke-opacity', 1) - .classed('destination-node', false); - } - }; - }, [destinationNodeId, settings.interaction.showOriginNode, data, applyBlueRing]); - - // Handle window resize - useEffect(() => { - const handleResize = () => { - if (svgRef.current) { - const rect = svgRef.current.parentElement?.getBoundingClientRect(); - if (rect) { - setDimensions({ width: rect.width, height: rect.height }); - } - } - }; - - window.addEventListener('resize', handleResize); - handleResize(); - - return () => window.removeEventListener('resize', handleResize); - }, []); - - // Helper: Merge new graph data with existing (deduplicate nodes/links) - // Preserves existing node positions to prevent force explosion - const mergeGraphData = useCallback((newData: any) => { - if (!graphData || !graphData.nodes || graphData.nodes.length === 0) { - return newData; - } - - // Create map of existing nodes with their positions - const existingNodesMap = new Map( - graphData.nodes.map((n: any) => [n.id, n]) - ); - - const mergedNodes: any[] = []; - - // First, add all existing nodes (preserving positions) - graphData.nodes.forEach((node: any) => { - mergedNodes.push(node); - }); - - // Then add new nodes (they'll get positioned by force simulation) - newData.nodes.forEach((node: any) => { - if (!existingNodesMap.has(node.id)) { - // New node - position it near the center of existing graph - const existingPositions = graphData.nodes - .filter((n: any) => n.x !== undefined && n.y !== undefined) - .map((n: any) => ({ x: n.x, y: n.y })); - - if (existingPositions.length > 0) { - // Calculate centroid of existing nodes - const centerX = existingPositions.reduce((sum, p) => sum + p.x, 0) / existingPositions.length; - const centerY = existingPositions.reduce((sum, p) => sum + p.y, 0) / existingPositions.length; - - // Add small random offset to avoid exact overlap - node.x = centerX + (Math.random() - 0.5) * 50; - node.y = centerY + (Math.random() - 0.5) * 50; - } - - mergedNodes.push(node); - } - }); - - // Merge links (deduplicate by source -> target -> type) - const existingLinks = graphData.links || []; - const existingLinkKeys = new Set( - existingLinks.map((l: any) => { - const sourceId = typeof l.source === 'string' ? l.source : l.source.id; - const targetId = typeof l.target === 'string' ? l.target : l.target.id; - return `${sourceId}->${targetId}:${l.type}`; - }) - ); - const mergedLinks = [...existingLinks]; - - const newLinks = newData.links || []; - newLinks.forEach((link: any) => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - const key = `${sourceId}->${targetId}:${link.type}`; - if (!existingLinkKeys.has(key)) { - mergedLinks.push(link); - existingLinkKeys.add(key); - } - }); - - return { - nodes: mergedNodes, - links: mergedLinks, - }; - }, [graphData]); - - // Use common graph navigation hook - const { handleFollowConcept, handleAddToGraph, handleRemoveFromGraph, handleTravelPath, handleSendToPolarity, handleSendPathToReports } = useGraphNavigation(); - - // Pin/Unpin node functionality - const isPinned = useCallback((nodeId: string): boolean => { - const node = data.nodes.find(n => n.id === nodeId); - return node?.fx !== undefined && node?.fx !== null; - }, [data.nodes]); - - const togglePinNode = useCallback((nodeId: string) => { - if (!svgRef.current) return; - - const svg = d3.select(svgRef.current); - const nodeSelection = svg.select(`circle[data-node-id="${nodeId}"]`); - - if (!nodeSelection.empty()) { - const nodeData = nodeSelection.datum() as RenderNode; - - if (nodeData.fx !== undefined && nodeData.fx !== null) { - // Unpin: remove fixed position - nodeData.fx = null; - nodeData.fy = null; - } else { - // Pin: set fixed position to current position - nodeData.fx = nodeData.x; - nodeData.fy = nodeData.y; - } - - // Restart simulation briefly to apply changes - if (settings.physics.enabled && simulationRef.current) { - simulationRef.current.alpha(0.1).restart(); - } - } - }, [filteredData.nodes, settings.physics.enabled]); - - const unpinAllNodes = useCallback(() => { - if (!svgRef.current) return; - - const svg = d3.select(svgRef.current); - - // Unpin all nodes - svg.selectAll('circle[data-node-id]').each(function() { - const nodeData = d3.select(this).datum() as RenderNode; - nodeData.fx = null; - nodeData.fy = null; - }); - - // Restart simulation to let forces take over - if (settings.physics.enabled && simulationRef.current) { - simulationRef.current.alpha(0.3).restart(); - } - }, [settings.physics.enabled]); - - // Travel to origin node (center it in viewport with zoom) - const travelToOrigin = useCallback(() => { - if (!originNodeId || !svgRef.current || !zoomBehaviorRef.current) return; - - const originNode = data.nodes.find(n => n.id === originNodeId); - if (!originNode || originNode.x === undefined || originNode.y === undefined) return; - - const svg = d3.select(svgRef.current); - const width = dimensions.width; - const height = dimensions.height; - - // Calculate transform to center the origin node in viewport - const scale = 1.5; // Zoom in to 1.5x for better focus - const x = width / 2 - originNode.x * scale; // Center horizontally - const y = height / 2 - originNode.y * scale; // Center vertically - - // Animate transition with cubic ease in/out (smooth acceleration/deceleration like mouse pan) - // Speed automatically adjusts based on distance - farther = faster, but same duration - svg.transition() - .duration(750) - .ease(d3.easeCubicInOut) // Smooth acceleration at start, deceleration at end - .call( - zoomBehaviorRef.current.transform, - d3.zoomIdentity.translate(x, y).scale(scale) - ); - }, [originNodeId, data.nodes, dimensions]); - - // Travel to destination node (center it in viewport with zoom) - const travelToDestination = useCallback(() => { - if (!destinationNodeId || !svgRef.current || !zoomBehaviorRef.current) return; - - const destinationNode = data.nodes.find(n => n.id === destinationNodeId); - if (!destinationNode || destinationNode.x === undefined || destinationNode.y === undefined) return; - - const svg = d3.select(svgRef.current); - const width = dimensions.width; - const height = dimensions.height; - - // Calculate transform to center the destination node in viewport - const scale = 1.5; // Zoom in to 1.5x for better focus - const x = width / 2 - destinationNode.x * scale; // Center horizontally - const y = height / 2 - destinationNode.y * scale; // Center vertically - - // Animate transition with cubic ease in/out (smooth acceleration/deceleration like mouse pan) - // Speed automatically adjusts based on distance - farther = faster, but same duration - svg.transition() - .duration(750) - .ease(d3.easeCubicInOut) // Smooth acceleration at start, deceleration at end - .call( - zoomBehaviorRef.current.transform, - d3.zoomIdentity.translate(x, y).scale(scale) - ); - }, [destinationNodeId, data.nodes, dimensions]); - - // Travel along a path — sequentially animate camera through each node - const travelAlongPath = useCallback((nodeIds: string[], reverse = false) => { - if (!svgRef.current || !zoomBehaviorRef.current || nodeIds.length === 0) return; - - const orderedIds = reverse ? [...nodeIds].reverse() : nodeIds; - const svg = d3.select(svgRef.current); - const { width, height } = dimensions; - const scale = 1.5; - - let step = 0; - const visitNext = () => { - if (step >= orderedIds.length) return; - const node = data.nodes.find(n => n.id === orderedIds[step]); - if (!node || node.x === undefined || node.y === undefined) { - step++; - visitNext(); - return; - } - - const x = width / 2 - node.x * scale; - const y = height / 2 - node.y * scale; - - svg.transition() - .duration(750) - .ease(d3.easeCubicInOut) - .call( - zoomBehaviorRef.current!.transform, - d3.zoomIdentity.translate(x, y).scale(scale) - ) - .on('end', () => { - step++; - setTimeout(visitNext, 400); - }); - }; - - visitNext(); - }, [data.nodes, dimensions]); - - // Build unified context menu items (context-aware for node vs background) - const contextMenuItems: ContextMenuItem[] = contextMenu - ? buildContextMenuItems( - // Pass node context (null for background clicks) - contextMenu.nodeId && contextMenu.nodeLabel - ? { nodeId: contextMenu.nodeId, nodeLabel: contextMenu.nodeLabel } - : null, - { - handleFollowConcept, - handleAddToGraph, - handleRemoveFromGraph, - setOriginNode: setOriginNodeId, - setDestinationNode: setDestinationNodeId, - travelToOrigin, - travelToDestination, - travelAlongPath, - setFocusedNode, - focusedNodeId: focusedNode, - isPinned, - togglePinNode, - unpinAllNodes, - applyOriginMarker: applyGoldRing, - applyDestinationMarker: applyBlueRing, - }, - { onClose: () => setContextMenu(null) }, - originNodeId, - destinationNodeId, - { handleTravelPath, handleSendToPolarity, handleSendPathToReports, handleSendConceptToReports: onSendToReports } - ) - : []; - - // Dismiss edge info box - const handleDismissEdgeInfo = useCallback((linkKey: string) => { - setActiveEdgeInfos(prev => prev.filter(info => info.linkKey !== linkKey)); - }, []); - - // Dismiss node info box - const handleDismissNodeInfo = useCallback((nodeId: string) => { - setActiveNodeInfos(prev => prev.filter(info => info.nodeId !== nodeId)); - }, []); - - return ( -
- { - // Close context menu when clicking on canvas background - setContextMenu(null); - }} - onMouseDown={(e) => { - // Track right-click start position for drag detection - if (e.button === 2) { // Right mouse button - rightClickStartRef.current = { - x: e.clientX, - y: e.clientY, - time: Date.now(), - }; - } - }} - onMouseMove={(e) => { - // Check if right-click is being dragged - if (rightClickStartRef.current && e.buttons === 2) { // Right button still pressed - const dx = e.clientX - rightClickStartRef.current.x; - const dy = e.clientY - rightClickStartRef.current.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - // If moved more than 10 pixels, treat as pan (not context menu) - if (distance > 10) { - setIsRightClickDragging(true); - } - } - }} - onMouseUp={(e) => { - // Reset right-click drag tracking - if (e.button === 2) { // Right mouse button released - rightClickStartRef.current = null; - setIsRightClickDragging(false); - } - }} - onContextMenu={(e) => { - e.preventDefault(); // Always prevent default browser context menu - - // Only show background context menu if NOT dragging - // (nodes/edges have their own context menu handlers that stopPropagation) - if (!isRightClickDragging) { - // Show unified context menu with null node context (background click) - setContextMenu({ - x: e.clientX, - y: e.clientY, - nodeId: null, - nodeLabel: null, - }); - } else { - // Was dragging, so don't show context menu - setIsRightClickDragging(false); - } - }} - /> - - {/* Left-side panel stack */} - - - - - {/* Right-side panel stack */} - - {/* Stats and Send to Reports row */} -
- - {onSendToReports && ( - - )} -
- - - - {/* Unified Context Menu (context-aware for node vs background) */} - {contextMenu && ( - setContextMenu(null)} - /> - )} - - {/* Edge Info Boxes */} -
- {activeEdgeInfos.map(info => { - // Apply zoom transform to graph coordinates - const screenX = info.x * zoomTransform.k + zoomTransform.x; - const screenY = info.y * zoomTransform.k + zoomTransform.y; - return ( - handleDismissEdgeInfo(info.linkKey)} - /> - ); - })} -
- - {/* Node Info Boxes */} -
- {activeNodeInfos.map(info => { - // Apply zoom transform to graph coordinates - const screenX = info.x * zoomTransform.k + zoomTransform.x; - const screenY = info.y * zoomTransform.k + zoomTransform.y; - return ( - handleDismissNodeInfo(info.nodeId)} - /> - ); - })} -
-
- ); -}; diff --git a/web/src/explorers/ForceGraph2D/ProfilePanel.tsx b/web/src/explorers/ForceGraph2D/ProfilePanel.tsx deleted file mode 100644 index 27437e03b..000000000 --- a/web/src/explorers/ForceGraph2D/ProfilePanel.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Profile Panel (Placeholder) - * - * Satisfies the explorer plugin interface requirement for a settings panel. - * Canvas settings are handled by CanvasSettingsPanel (on-graph UI). - * This panel is reserved for future user profile settings. - */ - -import React from 'react'; -import type { SettingsPanelProps } from '../../types/explorer'; -import type { ForceGraph2DSettings } from './types'; - -export const ProfilePanel: React.FC> = () => { - return ( -
-
-

Profile

-

- User profile and preferences coming soon. -

-

- Graph visualization settings are available in the settings panel on the canvas - (upper right corner). -

-
-
- ); -}; diff --git a/web/src/explorers/ForceGraph2D/index.ts b/web/src/explorers/ForceGraph2D/index.ts deleted file mode 100644 index fa962fc30..000000000 --- a/web/src/explorers/ForceGraph2D/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Force-Directed 2D Graph Explorer - Plugin Definition - * - * Follows ADR-034 Explorer Plugin Interface. - * This is the Phase 1 MVP explorer. - */ - -import { Network } from 'lucide-react'; -import type { ExplorerPlugin } from '../../types/explorer'; -import { ForceGraph2D } from './ForceGraph2D'; -import { ProfilePanel } from './ProfilePanel'; -import type { ForceGraph2DSettings, ForceGraph2DData } from './types'; -import { DEFAULT_SETTINGS } from './types'; -import { transformForD3 } from '../../utils/graphTransform'; - -/** - * Force-Directed 2D Graph Explorer Plugin - * - * Interactive 2D force-directed graph visualization with physics simulation. - * Best for exploring conceptual neighborhoods and relationship patterns. - */ -export const ForceGraph2DExplorer: ExplorerPlugin = { - config: { - id: 'force-2d', - type: 'force-2d', - name: 'Force-Directed 2D', - description: 'Explore conceptual neighborhoods with physics-based layout', - icon: Network, - requiredDataShape: 'graph', - }, - - component: ForceGraph2D, - settingsPanel: ProfilePanel, - - dataTransformer: (apiData) => { - // Always transform raw API data to ensure proper field names (concept_id → id, from_id → source, etc.) - return transformForD3(apiData.nodes || [], apiData.links || []); - }, - - defaultSettings: DEFAULT_SETTINGS, -}; - -// Auto-register this explorer -import { registerExplorer } from '../registry'; -registerExplorer(ForceGraph2DExplorer); diff --git a/web/src/explorers/ForceGraph2D/types.ts b/web/src/explorers/ForceGraph2D/types.ts deleted file mode 100644 index ec1263f31..000000000 --- a/web/src/explorers/ForceGraph2D/types.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Force-Directed 2D Graph Explorer - Type Definitions - */ - -import type { GraphData } from '../../types/graph'; -import type { NodeColorMode } from '../common'; - -export type { NodeColorMode }; -export type EdgeColorMode = 'category' | 'confidence' | 'uniform'; -export type LayoutAlgorithm = 'force' | 'circular' | 'grid'; - -export interface ForceGraph2DSettings { - // Physics simulation - physics: { - enabled: boolean; - charge: number; // Repulsion strength (-100 to -1000) - linkDistance: number; // Target link distance (10-400) - gravity: number; // Center gravity (0-1) - friction: number; // Velocity decay (0-1) - warmth: number; // Initial simulation energy (0.1-1.0, lower = less scatter) - }; - - // Visual appearance - visual: { - nodeColorBy: NodeColorMode; - edgeColorBy: EdgeColorMode; - showLabels: boolean; - showArrows: boolean; - showGrid: boolean; - showShadows: boolean; // 3D-style shadows and highlights - nodeSize: number; // Base node size multiplier (0.5-3) - linkWidth: number; // Base link width (0.5-5) - nodeLabelSize: number; // Node label font size (6-20px) - edgeLabelSize: number; // Edge label font size (6-20px) - }; - - // Interaction - interaction: { - enableDrag: boolean; - enableZoom: boolean; - enablePan: boolean; - highlightNeighbors: boolean; - showOriginNode: boolean; // "You Are Here" highlighting - }; - - // Filters - filters: { - relationshipTypes: string[]; - ontologies: string[]; - minConfidence: number; // 0-1 - }; - - // Layout - layout: LayoutAlgorithm; -} - -export interface ForceGraph2DData extends GraphData { - // Already has nodes and links -} - -export const DEFAULT_SETTINGS: ForceGraph2DSettings = { - physics: { - enabled: true, - charge: -750, // Strong repulsion for clear spacing in 2D - linkDistance: 200, // Longer links for better graph layout - gravity: 0.1, - friction: 0.9, - warmth: 0.3, // Lower = gentler settle, higher = more scatter - }, - visual: { - nodeColorBy: 'ontology', - edgeColorBy: 'category', - showLabels: true, // Enabled by default for better readability - showArrows: true, - showGrid: true, - showShadows: false, // Disabled by default for performance - nodeSize: 1.9, // Larger nodes for better visibility - linkWidth: 1.0, - nodeLabelSize: 12, - edgeLabelSize: 12, // Consistent with node labels in 2D plane - }, - interaction: { - enableDrag: true, - enableZoom: true, - enablePan: true, - highlightNeighbors: true, - showOriginNode: true, - }, - filters: { - relationshipTypes: [], - ontologies: [], - minConfidence: 0, - }, - layout: 'force', -}; - -// Slider range configurations for 2D graph -export const SLIDER_RANGES = { - physics: { - charge: { min: -1000, max: -100, step: 50 }, - linkDistance: { min: 10, max: 400, step: 10 }, // Extended range for larger graphs - gravity: { min: 0, max: 1, step: 0.05 }, - warmth: { min: 0.1, max: 1, step: 0.05 }, - }, - visual: { - nodeSize: { min: 0.5, max: 3, step: 0.1 }, - linkWidth: { min: 0.5, max: 5, step: 0.1 }, - nodeLabelSize: { min: 6, max: 20, step: 1 }, - edgeLabelSize: { min: 6, max: 20, step: 1 }, // Smaller range for 2D (everything at same viewing distance) - }, -}; diff --git a/web/src/explorers/common/GraphSettingsPanel.tsx b/web/src/explorers/common/GraphSettingsPanel.tsx deleted file mode 100644 index 68a8005ff..000000000 --- a/web/src/explorers/common/GraphSettingsPanel.tsx +++ /dev/null @@ -1,424 +0,0 @@ -/** - * Graph Settings Panel - * - * Common settings panel for physics, visual, and interaction controls. - * Shared between 2D and 3D graph explorers. - */ - -import React, { useState } from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import type { ForceGraph2DSettings } from '../ForceGraph2D/types'; - -// Generic settings interface - both 2D and 3D settings must have these -interface GraphSettings { - physics: { - enabled: boolean; - charge: number; - linkDistance: number; - gravity: number; - friction: number; - warmth?: number; - }; - visual: { - nodeColorBy: string; - edgeColorBy: string; - showLabels: boolean; - showArrows: boolean; - showGrid: boolean; - showShadows: boolean; - nodeSize: number; - linkWidth: number; - nodeLabelSize?: number; - edgeLabelSize?: number; - }; - interaction: { - enableDrag: boolean; - enableZoom: boolean; - enablePan: boolean; - highlightNeighbors: boolean; - showOriginNode: boolean; - }; -} - -interface SliderRanges { - physics: { - charge: { min: number; max: number; step: number }; - linkDistance: { min: number; max: number; step: number }; - gravity: { min: number; max: number; step: number }; - warmth?: { min: number; max: number; step: number }; - }; - visual: { - nodeSize: { min: number; max: number; step: number }; - linkWidth: { min: number; max: number; step: number }; - nodeLabelSize?: { min: number; max: number; step: number }; - edgeLabelSize?: { min: number; max: number; step: number }; - }; -} - -interface GraphSettingsPanelProps { - settings: T; - onChange: (settings: T) => void; - sliderRanges: SliderRanges; - embedded?: boolean; -} - -export const GraphSettingsPanel = ({ - settings, - onChange, - sliderRanges, - embedded = false, -}: GraphSettingsPanelProps) => { - const [expandedSections, setExpandedSections] = useState>(new Set()); - - const toggleSection = (section: string) => { - setExpandedSections((prev) => { - const next = new Set(prev); - if (next.has(section)) { - next.delete(section); - } else { - next.add(section); - } - return next; - }); - }; - - const updatePhysics = (key: keyof ForceGraph2DSettings['physics'], value: number | boolean) => { - onChange({ - ...settings, - physics: { ...settings.physics, [key]: value }, - }); - }; - - const updateVisual = (key: keyof ForceGraph2DSettings['visual'], value: any) => { - onChange({ - ...settings, - visual: { ...settings.visual, [key]: value }, - }); - }; - - const updateInteraction = (key: keyof ForceGraph2DSettings['interaction'], value: boolean) => { - onChange({ - ...settings, - interaction: { ...settings.interaction, [key]: value }, - }); - }; - - return ( -
- {/* Content */} -
- {/* Physics Section */} -
- - {expandedSections.has('physics') && ( -
- - - {settings.physics.enabled && ( - <> -
- - updatePhysics('charge', parseInt(e.target.value))} - className="w-full" - /> -
- -
- - updatePhysics('linkDistance', parseInt(e.target.value))} - className="w-full" - /> -
- -
- - updatePhysics('gravity', parseFloat(e.target.value))} - className="w-full" - /> -
- - {settings.physics.warmth !== undefined && sliderRanges.physics.warmth && ( -
- - updatePhysics('warmth', parseFloat(e.target.value))} - className="w-full" - /> -
- )} - - )} -
- )} -
- - {/* Visual Section */} -
- - {expandedSections.has('visual') && ( -
-
- - -
- -
- - -
- - - - - - - - - -
- - updateVisual('nodeSize', parseFloat(e.target.value))} - className="w-full" - /> -
- -
- - updateVisual('linkWidth', parseFloat(e.target.value))} - className="w-full" - /> -
- -
- - updateVisual('nodeLabelSize', parseInt(e.target.value))} - className="w-full" - /> -
- -
- - updateVisual('edgeLabelSize', parseInt(e.target.value))} - className="w-full" - /> -
-
- )} -
- - {/* Interaction Section */} -
- - {expandedSections.has('interaction') && ( -
- - - - - - - - - -
- )} -
-
-
- ); -}; diff --git a/web/src/explorers/common/index.ts b/web/src/explorers/common/index.ts index 9247d4069..80e41b216 100644 --- a/web/src/explorers/common/index.ts +++ b/web/src/explorers/common/index.ts @@ -6,7 +6,6 @@ export { NodeInfoBox, type NodeInfoBoxProps } from './NodeInfoBox'; export { EdgeInfoBox, type EdgeInfoBoxProps } from './EdgeInfoBox'; export { StatsPanel, type StatsPanelProps } from './StatsPanel'; -export { GraphSettingsPanel } from './GraphSettingsPanel'; export { Legend } from './Legend'; export { PanelStack } from './PanelStack'; export { formatGrounding, getRelationshipTextColor } from './utils'; diff --git a/web/src/explorers/index.ts b/web/src/explorers/index.ts index 18a6681ce..b81fabac7 100644 --- a/web/src/explorers/index.ts +++ b/web/src/explorers/index.ts @@ -5,11 +5,9 @@ */ export * from './registry'; -export { ForceGraph2DExplorer } from './ForceGraph2D'; export { ForceGraph3DExplorer, ForceGraph2DV2Explorer } from './ForceGraph3D'; export { DocumentExplorerPlugin } from './DocumentExplorer'; // Import explorers to trigger auto-registration -import './ForceGraph2D'; import './ForceGraph3D'; import './DocumentExplorer'; diff --git a/web/src/hooks/useGraphData.ts b/web/src/hooks/useGraphData.ts index 3fcfab387..27d4a1ffd 100644 --- a/web/src/hooks/useGraphData.ts +++ b/web/src/hooks/useGraphData.ts @@ -6,9 +6,8 @@ */ import { useMemo } from 'react'; -import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueries } from '@tanstack/react-query'; import { apiClient } from '../api/client'; -import { transformForD3 } from '../utils/graphTransform'; /** * Fetch subgraph centered on a concept @@ -382,24 +381,3 @@ export function usePathEnrichment( return { data, isLoading }; } -/** - * Prefetch subgraph for a concept - * Useful for preloading data when hovering over nodes - */ -export function usePrefetchSubgraph() { - const queryClient = useQueryClient(); - - return (conceptId: string, depth = 1) => { - queryClient.prefetchQuery({ - queryKey: ['subgraph', conceptId, depth], - queryFn: async () => { - const response = await apiClient.getSubgraph({ - center_concept_id: conceptId, - depth, - }); - return transformForD3(response.nodes, response.links); - }, - staleTime: 5 * 60 * 1000, - }); - }; -} diff --git a/web/src/store/graphStore.ts b/web/src/store/graphStore.ts index 4bc556fff..778247331 100644 --- a/web/src/store/graphStore.ts +++ b/web/src/store/graphStore.ts @@ -286,7 +286,7 @@ export const useGraphStore = create()( persist( (set) => ({ // Explorer selection - selectedExplorer: 'force-2d', + selectedExplorer: 'force-3d', setSelectedExplorer: (type) => set({ selectedExplorer: type }), // Graph data (raw API data - cached) diff --git a/web/src/types/explorer.ts b/web/src/types/explorer.ts index a351d0191..22c92517e 100644 --- a/web/src/types/explorer.ts +++ b/web/src/types/explorer.ts @@ -9,7 +9,6 @@ import type { ComponentType } from 'react'; import type { RawGraphData } from '../utils/cypherResultMapper'; export type VisualizationType = - | 'force-2d' | 'force-2d-v2' | 'force-3d' | 'document' diff --git a/web/src/utils/graphTransform.ts b/web/src/utils/graphTransform.ts deleted file mode 100644 index 9f7c7ee17..000000000 --- a/web/src/utils/graphTransform.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Graph Data Transformation - * - * Transforms API graph data to D3/Three.js visualization formats. - */ - -import { useVocabularyStore } from '../store/vocabularyStore'; -import { getCategoryColor } from '../config/categoryColors'; -import { createOntologyColorScale } from './colorScale'; -import type { - APIGraphNode, - APIGraphLink, - RenderNode, - RenderLink, - GraphData, - Node3D, - Link3D, - Graph3DData, -} from '../types/graph'; -import type { RawGraphNode, RawGraphLink } from './cypherResultMapper'; - -/** - * Transform API data to D3 2D format. - * Accepts both APIGraphNode/APIGraphLink (from API calls) and - * RawGraphNode/RawGraphLink (from graphStore/dataTransformer pipeline). - * Automatically enriches links with vocabulary category data. - */ -export function transformForD3( - apiNodes: (APIGraphNode | RawGraphNode)[], - apiLinks: (APIGraphLink | RawGraphLink)[] -): GraphData { - // Get vocabulary data from store - const vocabStore = useVocabularyStore.getState(); - - // Create color scale for ontologies using equidistant points on a color ramp - const ontologies = [...new Set(apiNodes.map(n => n.ontology || 'Unknown'))].sort(); - const colorScale = createOntologyColorScale(ontologies); - - // Transform nodes — spread all API fields, add visualization aliases - const nodes: RenderNode[] = apiNodes.map(node => { - const ontology = node.ontology || 'Unknown'; - return { - ...node, - id: node.concept_id, - ontology, - search_terms: node.search_terms || [], - group: ontology, - grounding: node.grounding_strength, - size: 10, // Will be updated with degree - color: colorScale(ontology), - }; - }); - - // Build node ID set for defensive link filtering - const nodeIdSet = new Set(nodes.map(n => n.id)); - - // Filter out links referencing non-existent nodes (defense in depth — - // cypherResultMapper should already drop these, but upstream data - // sources may also produce orphan references). - const validLinks = apiLinks.filter(link => { - return nodeIdSet.has(link.from_id) && nodeIdSet.has(link.to_id); - }); - - // Transform links - enrich with vocabulary data from store - const links: RenderLink[] = validLinks.map(link => { - // Look up category from vocabulary store - let category = vocabStore.getCategory(link.relationship_type); - - if (!category) { - // If vocabulary lookup failed, check if API provided category - category = link.category; - } - - if (!category || category === 'default') { - // If still no category, group under "Uncategorized" instead of individual types - // This keeps the legend cleaner and indicates these need categorization - category = 'Uncategorized'; - } - - const categoryConfidence = vocabStore.getConfidence(link.relationship_type) || 1.0; - - return { - ...link, - source: link.from_id, - target: link.to_id, - type: link.relationship_type, - value: categoryConfidence, - color: getCategoryColor(category), - category, - }; - }); - - // Calculate node degrees and update sizes - const degrees = new Map(); - links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - degrees.set(sourceId, (degrees.get(sourceId) || 0) + 1); - degrees.set(targetId, (degrees.get(targetId) || 0) + 1); - }); - - nodes.forEach(node => { - const degree = degrees.get(node.id) || 1; - // Logarithmic scaling for node size (5-30 range) - node.size = Math.max(5, Math.min(30, 5 + Math.log(degree + 1) * 5)); - }); - - return { nodes, links }; -} - -/** - * Transform API data to Three.js 3D format - */ -export function transformFor3D( - apiNodes: (APIGraphNode | RawGraphNode)[], - apiLinks: (APIGraphLink | RawGraphLink)[] -): Graph3DData { - // Start with 2D transform - const { nodes: nodes2d, links: links2d } = transformForD3(apiNodes, apiLinks); - - // Convert to 3D nodes - const nodes: Node3D[] = nodes2d.map(node => ({ - ...node, - z: 0, // Will be positioned by force simulation - })); - - // Convert to 3D links - const links: Link3D[] = links2d.map(link => ({ - ...link, - })); - - return { nodes, links }; -} - -/** - * Filter graph data by relationship types - */ -export function filterByRelationshipType( - data: GraphData, - types: string[] -): GraphData { - const filteredLinks = data.links.filter(link => types.includes(link.type)); - - // Get all node IDs that are connected by filtered links - const connectedNodeIds = new Set(); - filteredLinks.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - connectedNodeIds.add(sourceId); - connectedNodeIds.add(targetId); - }); - - // Filter nodes to only include connected ones - const filteredNodes = data.nodes.filter(node => connectedNodeIds.has(node.id)); - - return { - nodes: filteredNodes, - links: filteredLinks, - }; -} - -/** - * Filter graph data by edge categories - * Hides edges with invisible categories AND their target nodes - * (keeps source nodes as they may have other visible connections) - */ -export function filterByEdgeCategory( - data: GraphData, - visibleCategories: Set -): GraphData { - // If empty set, show all (default state) - if (visibleCategories.size === 0) { - return data; - } - - // Filter links to only include visible categories - const filteredLinks = data.links.filter(link => - visibleCategories.has(link.category || 'default') - ); - - // Get all node IDs that have ANY connections (visible or not) - const allConnectedNodeIds = new Set(); - data.links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - allConnectedNodeIds.add(sourceId); - allConnectedNodeIds.add(targetId); - }); - - // Get node IDs that have visible connections - const visiblyConnectedNodeIds = new Set(); - filteredLinks.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - visiblyConnectedNodeIds.add(sourceId); - visiblyConnectedNodeIds.add(targetId); - }); - - // Keep nodes that either: - // 1. Have visible connections, or - // 2. Have no connections at all (isolated nodes from additive loading) - const filteredNodes = data.nodes.filter(node => - visiblyConnectedNodeIds.has(node.id) || !allConnectedNodeIds.has(node.id) - ); - - return { - nodes: filteredNodes, - links: filteredLinks, - }; -} - -/** - * Get neighbors of a node - */ -export function getNeighbors(nodeId: string, links: RenderLink[]): Set { - const neighbors = new Set(); - - links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - if (sourceId === nodeId) { - neighbors.add(targetId); - } - if (targetId === nodeId) { - neighbors.add(sourceId); - } - }); - - return neighbors; -} - -/** - * Find hub nodes (high degree centrality) - */ -export function findHubNodes(data: GraphData, topN: number = 10): RenderNode[] { - // Calculate degrees - const degrees = new Map(); - - data.links.forEach(link => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - degrees.set(sourceId, (degrees.get(sourceId) || 0) + 1); - degrees.set(targetId, (degrees.get(targetId) || 0) + 1); - }); - - // Sort nodes by degree - const sortedNodes = [...data.nodes].sort((a, b) => { - const degreeA = degrees.get(a.id) || 0; - const degreeB = degrees.get(b.id) || 0; - return degreeB - degreeA; - }); - - return sortedNodes.slice(0, topN); -} diff --git a/web/src/views/ExplorerView.tsx b/web/src/views/ExplorerView.tsx index b28596faa..23ed168db 100644 --- a/web/src/views/ExplorerView.tsx +++ b/web/src/views/ExplorerView.tsx @@ -22,8 +22,6 @@ import { useQueryDefinitionStore } from '../store/queryDefinitionStore'; import type { GraphReportData } from '../store/reportStore'; import { useSubgraph, useFindConnection, usePathEnrichment } from '../hooks/useGraphData'; import { getExplorer } from '../explorers'; -import { GraphSettingsPanel } from '../explorers/common/GraphSettingsPanel'; -import { SLIDER_RANGES as SLIDER_RANGES_2D } from '../explorers/ForceGraph2D/types'; import { getZIndexValue } from '../config/zIndex'; import { generateCypher, parseCypherStatements } from '../utils/cypherGenerator'; import type { RawGraphNode, RawGraphLink } from '../utils/cypherResultMapper'; @@ -237,11 +235,11 @@ export const ExplorerView: React.FC = ({ explorerType }) => { const explorerPlugin = getExplorer(explorerType); // Derive the current explorer's data shape synchronously from rawGraphData. - // Each explorer's dataTransformer produces its own shape — the V1 2D/3D - // graphs expect {nodes, links}, V2 expects {nodes, edges}, etc. Computing + // Each explorer's dataTransformer produces its own shape. Computing // inline with useMemo means switching explorers never shows one - // explorer's component the previous explorer's shape (which crashes - // cross-shape access like data.links being undefined under V2's shape). + // explorer's component the previous explorer's shape (which would crash + // on cross-shape access like data.links being undefined under + // {nodes, edges} shape). const graphData = useMemo(() => { if (!rawGraphData || !explorerPlugin) return null; return explorerPlugin.dataTransformer(rawGraphData); @@ -367,20 +365,12 @@ export const ExplorerView: React.FC = ({ explorerType }) => { /> ); - // Settings panel content. The 3D explorer declares its own settings - // panel via the plugin contract (ADR-034). The 2D explorer is the last - // consumer of the legacy GraphSettingsPanel + SLIDER_RANGES_2D shape. + // Settings panel content — each explorer plugin declares its own settings + // panel via the ExplorerPlugin contract (ADR-034). const PluginSettingsPanel = explorerPlugin?.settingsPanel; const settingsPanelContent = (
- {explorerType === 'force-2d' && explorerSettings?.physics ? ( - - ) : PluginSettingsPanel ? ( + {PluginSettingsPanel ? ( ) : (
From e836f6832a5e71aef76425e59ff92ec8f00265f9 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 08:34:27 -0500 Subject: [PATCH 2/3] refactor(web): consolidate to single Force Graph plugin with projection toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three plugin entries (force-2d-v2, force-3d, plus the now-retired d3 force-2d) collapse into one `force-graph` plugin backed by the unified r3f + GPU engine (ADR-702). Projection is a user-facing setting inside the plugin — the engine dispatches camera, drag plane, and sim axis count from `settings.projection`. User-visible changes: - Sidebar shows a single "Force Graph" entry (was two) - Canonical route is `/explore/graph` - Legacy `/explore/2d` and `/explore/3d` redirect to `/explore/graph` via a small `RedirectPreservingSearch` helper, so bookmarked concept ids and depth params carry over - `/explore/2d-v2` removed without a redirect — it was an in-flight dev-only route from PR #366 Code changes: - VisualizationType: drop `force-2d-v2`, `force-3d`; add `force-graph` - `ForceGraph3D/index.ts`: drop the `createForceGraphPlugin` factory; export a single `ForceGraphExplorer` - Reports record the active projection from settings (not from the explorer type, which is no longer 2D/3D-discriminated) - Home screen NavigationGraph replaces the two 2D/3D nodes with one Force Graph node - AppLayout drops the now-unused Boxes and Map icons; uses Network for the consolidated explorer - Zustand `selectedExplorer` defaults to `'force-graph'` --- web/src/App.tsx | 18 +++++- web/src/components/home/NavigationGraph.tsx | 3 +- web/src/components/layout/AppLayout.tsx | 20 ++---- web/src/explorers/ForceGraph3D/index.ts | 69 +++++++-------------- web/src/explorers/index.ts | 2 +- web/src/store/graphStore.ts | 2 +- web/src/types/explorer.ts | 3 +- web/src/views/ExplorerView.tsx | 6 +- 8 files changed, 52 insertions(+), 71 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 861ba8d0d..89e6c4b83 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,7 +7,7 @@ import React, { useEffect } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { AppLayout } from './components/layout/AppLayout'; import { OAuthCallback } from './components/auth/OAuthCallback'; import { useVocabularyStore } from './store/vocabularyStore'; @@ -125,8 +125,12 @@ const AppContent: React.FC = () => { } /> {/* Explorers */} - } /> - } /> + } /> + {/* Legacy public routes — preserve bookmarks. The unified plugin + owns projection via its settings panel, so the URL no longer + encodes 2D vs 3D. Search params (concept ids, depth, etc.) carry over. */} + } /> + } /> } /> {/* Block Editor */} @@ -165,6 +169,14 @@ const AppContent: React.FC = () => { ); }; +/** Redirect that preserves the incoming `?…` search string. Used to keep + * legacy explorer routes (`/explore/2d`, `/explore/3d`) honoring their + * bookmarked concept-id and depth query params after consolidation. */ +const RedirectPreservingSearch: React.FC<{ to: string }> = ({ to }) => { + const { search } = useLocation(); + return ; +}; + function App() { return ( diff --git a/web/src/components/home/NavigationGraph.tsx b/web/src/components/home/NavigationGraph.tsx index 3d0742641..6c547753b 100644 --- a/web/src/components/home/NavigationGraph.tsx +++ b/web/src/components/home/NavigationGraph.tsx @@ -45,8 +45,7 @@ const NODES: GraphNode[] = [ { id: 'ingest', label: 'Ingest', x: 160, y: 310, route: '/ingest', category: 'data', size: 28 }, // Explorers - { id: 'graph2d', label: '2D Graph', x: 350, y: 70, route: '/explore/2d', category: 'explore', size: 28 }, - { id: 'graph3d', label: '3D Graph', x: 530, y: 55, route: '/explore/3d', category: 'explore', size: 28 }, + { id: 'forcegraph', label: 'Force\nGraph', x: 440, y: 60, route: '/explore/graph', category: 'explore', size: 32 }, { id: 'docexplorer', label: 'Document\nExplorer', x: 280, y: 195, route: '/explore/documents', category: 'explore', size: 26 }, // Analyzers diff --git a/web/src/components/layout/AppLayout.tsx b/web/src/components/layout/AppLayout.tsx index 79087f7c7..c47aa68c3 100644 --- a/web/src/components/layout/AppLayout.tsx +++ b/web/src/components/layout/AppLayout.tsx @@ -26,8 +26,7 @@ import { PencilLine, Settings, Shield, - Boxes, - Map, + Network, GitBranch, FlaskConical, Home, @@ -74,8 +73,7 @@ export const AppLayout: React.FC = ({ children }) => { const getWorkspaceName = () => { const path = location.pathname; if (path === '/') return 'Home'; - if (path === '/explore/2d-v2') return '2D Force Graph (V2)'; - if (path.startsWith('/explore/3d')) return '3D Force Graph'; + if (path.startsWith('/explore/graph')) return 'Force Graph'; if (path.startsWith('/explore/documents')) return 'Document Explorer'; if (path.startsWith('/blocks')) return 'Block Editor'; if (path.startsWith('/ingest')) return 'Ingest'; @@ -127,16 +125,10 @@ export const AppLayout: React.FC = ({ children }) => { {/* Explorers */} navigate('/explore/2d-v2')} - /> - navigate('/explore/3d')} + icon={Network} + label="Force Graph" + isActive={location.pathname.startsWith('/explore/graph')} + onClick={() => navigate('/explore/graph')} /> , - projection: Projection -): ExplorerPlugin { - return { - config: { id: type, type, name, description, icon, requiredDataShape: 'graph' }, - component: ForceGraph3D, - settingsPanel: SettingsPanel, - dataTransformer: transformForEngine, - defaultSettings: { ...DEFAULT_SETTINGS, projection }, - }; -} - -export const ForceGraph3DExplorer = createForceGraphPlugin( - 'force-3d', - 'Force-Directed 3D', - 'Unified r3f + GPU engine — 3D with instanced nodes and shader-driven edges', - Boxes, - '3D' -); - -export const ForceGraph2DV2Explorer = createForceGraphPlugin( - 'force-2d-v2', - 'Force-Directed 2D (V2)', - 'Unified r3f + GPU engine — 2D projection on the same scene primitives', - Map, - '2D' -); +export const ForceGraphExplorer: ExplorerPlugin = { + config: { + id: 'force-graph', + type: 'force-graph', + name: 'Force Graph', + description: 'Unified r3f + GPU engine — 2D or 3D projection toggleable in settings', + icon: Network, + requiredDataShape: 'graph', + }, + component: ForceGraph3D, + settingsPanel: SettingsPanel, + dataTransformer: transformForEngine, + defaultSettings: DEFAULT_SETTINGS, +}; import { registerExplorer } from '../registry'; -registerExplorer(ForceGraph3DExplorer); -registerExplorer(ForceGraph2DV2Explorer); +registerExplorer(ForceGraphExplorer); diff --git a/web/src/explorers/index.ts b/web/src/explorers/index.ts index b81fabac7..e277a448c 100644 --- a/web/src/explorers/index.ts +++ b/web/src/explorers/index.ts @@ -5,7 +5,7 @@ */ export * from './registry'; -export { ForceGraph3DExplorer, ForceGraph2DV2Explorer } from './ForceGraph3D'; +export { ForceGraphExplorer } from './ForceGraph3D'; export { DocumentExplorerPlugin } from './DocumentExplorer'; // Import explorers to trigger auto-registration diff --git a/web/src/store/graphStore.ts b/web/src/store/graphStore.ts index 778247331..9dd34aec8 100644 --- a/web/src/store/graphStore.ts +++ b/web/src/store/graphStore.ts @@ -286,7 +286,7 @@ export const useGraphStore = create()( persist( (set) => ({ // Explorer selection - selectedExplorer: 'force-3d', + selectedExplorer: 'force-graph', setSelectedExplorer: (type) => set({ selectedExplorer: type }), // Graph data (raw API data - cached) diff --git a/web/src/types/explorer.ts b/web/src/types/explorer.ts index 22c92517e..0896bf735 100644 --- a/web/src/types/explorer.ts +++ b/web/src/types/explorer.ts @@ -9,8 +9,7 @@ import type { ComponentType } from 'react'; import type { RawGraphData } from '../utils/cypherResultMapper'; export type VisualizationType = - | 'force-2d-v2' - | 'force-3d' + | 'force-graph' | 'document' | 'hierarchy' | 'sankey' diff --git a/web/src/views/ExplorerView.tsx b/web/src/views/ExplorerView.tsx index 23ed168db..a05315a31 100644 --- a/web/src/views/ExplorerView.tsx +++ b/web/src/views/ExplorerView.tsx @@ -320,11 +320,13 @@ export const ExplorerView: React.FC = ({ explorerType }) => { name: '', type: 'graph', data: reportData, - sourceExplorer: explorerType === 'force-3d' ? '3d' : '2d', + // Record the active projection — drives report representation + // (`force_graph_2d` vs `force_graph_3d`) for downstream consumers. + sourceExplorer: (explorerSettings as { projection?: '2D' | '3D' })?.projection === '2D' ? '2d' : '3d', }); navigate('/report'); - }, [rawGraphData, searchParams, mode, explorerType, addReport, navigate]); + }, [rawGraphData, searchParams, mode, explorerSettings, addReport, navigate]); // Save current exploration as a query definition const handleSaveExploration = useCallback(async () => { From 5707bae30042f078199802c0d0ed0bee2c411293 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Thu, 14 May 2026 08:38:27 -0500 Subject: [PATCH 3/3] refactor(web): rename ForceGraph3D to ForceGraph and scrub V1/V2 framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After consolidation (previous commit), the engine handles both projections — the `3D` suffix on the directory, file, component, and type names became misleading. Renamed: - `web/src/explorers/ForceGraph3D/` → `web/src/explorers/ForceGraph/` - `ForceGraph3D.tsx` → `ForceGraph.tsx` - Component `ForceGraph3D` → `ForceGraph` - Type `ForceGraph3DData` → `ForceGraphData` - Type `ForceGraph3DSettings` → `ForceGraphSettings` - On-canvas debug label "ForceGraph3D" → "Force Graph" git tracks all moves as renames (no content drift), so blame history follows. Comment scrub for transitional V1/V2/d3-coexistence framing in `common/useGraphContextMenu.ts`, `common/nodeColors.ts`, `ForceGraph/scene/EdgeLabels.tsx`, and `ForceGraph/scene/Scene.tsx` — those readers no longer have a V1 to compare against. Ways updated to match: `web/explorers/explorers.md` references the new path and rewords the interaction-model section now that d3 ForceGraph2D is gone; `web/evolution/evolution.md` adds a Phase C reference. Per `.claude/ways/kg/web/evolution/evolution.md` ("Drop Transitional Framing Post-Promotion"). --- .claude/ways/kg/web/evolution/evolution.md | 4 ++++ .claude/ways/kg/web/explorers/explorers.md | 24 +++++++++---------- .../ForceGraph.tsx} | 21 ++++++++-------- .../SettingsPanel.tsx | 24 +++++++++---------- .../dataTransformer.ts | 6 ++--- .../{ForceGraph3D => ForceGraph}/index.ts | 8 +++---- .../scene/Arrows.tsx | 0 .../scene/EdgeLabels.tsx | 5 ++-- .../scene/Edges.tsx | 0 .../scene/NodeInfoOverlay.tsx | 0 .../scene/NodeLabels.tsx | 0 .../scene/Nodes.tsx | 0 .../scene/Overlays.tsx | 0 .../scene/Scene.tsx | 2 +- .../scene/bundles.ts | 0 .../scene/gpuShaders.ts | 0 .../scene/neighborCsr.ts | 0 .../scene/positions.ts | 0 .../scene/useDragHandler.ts | 0 .../scene/useForceSim.ts | 0 .../scene/useGpuForceSim.ts | 0 .../scene/useSim.ts | 0 .../{ForceGraph3D => ForceGraph}/types.ts | 15 ++++++------ web/src/explorers/common/nodeColors.ts | 14 +++++------ .../explorers/common/useGraphContextMenu.ts | 3 +-- web/src/explorers/index.ts | 4 ++-- 26 files changed, 65 insertions(+), 65 deletions(-) rename web/src/explorers/{ForceGraph3D/ForceGraph3D.tsx => ForceGraph/ForceGraph.tsx} (97%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/SettingsPanel.tsx (94%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/dataTransformer.ts (92%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/index.ts (81%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/Arrows.tsx (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/EdgeLabels.tsx (98%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/Edges.tsx (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/NodeInfoOverlay.tsx (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/NodeLabels.tsx (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/Nodes.tsx (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/Overlays.tsx (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/Scene.tsx (98%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/bundles.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/gpuShaders.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/neighborCsr.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/positions.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/useDragHandler.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/useForceSim.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/useGpuForceSim.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/scene/useSim.ts (100%) rename web/src/explorers/{ForceGraph3D => ForceGraph}/types.ts (91%) diff --git a/.claude/ways/kg/web/evolution/evolution.md b/.claude/ways/kg/web/evolution/evolution.md index 09a902cb2..25fc23043 100644 --- a/.claude/ways/kg/web/evolution/evolution.md +++ b/.claude/ways/kg/web/evolution/evolution.md @@ -89,3 +89,7 @@ user-facing affordance. refactor PRs) - PR #365 — Phase A: V1-3D retirement - PR #366 — Phase B: 2D projection + parity controls +- PR for Phase C — single Force Graph plugin; d3 ForceGraph2D retired, + the factory collapses to one plugin, `ForceGraph3D` symbol/dir renamed + to `ForceGraph` (the 3D suffix became misleading once 2D shared the + engine) diff --git a/.claude/ways/kg/web/explorers/explorers.md b/.claude/ways/kg/web/explorers/explorers.md index 9d2a285aa..17399d555 100644 --- a/.claude/ways/kg/web/explorers/explorers.md +++ b/.claude/ways/kg/web/explorers/explorers.md @@ -7,15 +7,17 @@ scope: agent, subagent --- # Force-Graph Explorers Way -Explorer plugins live in `web/src/explorers/`. The unified-engine -direction (ADR-702) is collapsing the 2D and 3D variants onto one R3F -engine; until that lands, both worlds coexist and their interaction -defaults need to agree. +Explorer plugins live in `web/src/explorers/`. The force-graph explorer +(`web/src/explorers/ForceGraph/`) is built on the unified R3F engine +(ADR-702); projection ('2D' / '3D') is a user-facing setting on the +plugin, and the engine dispatches camera, drag plane, and sim axis +count from it. -## Interaction Model: d3 ForceGraph2D is the Reference +## Interaction Model (carried over from the retired d3 ForceGraph2D) -For any 2D force-graph explorer in this project, the d3-based -`ForceGraph2D` defines the expected interaction defaults: +The d3-based `ForceGraph2D` plugin (retired in Phase C) established the +2D interaction defaults this codebase keeps. The unified engine's 2D +projection matches them, and any future 2D-style explorer should too: | Input | Action | |-------|--------| @@ -24,10 +26,8 @@ For any 2D force-graph explorer in this project, the d3-based | Right-click on background | Background context menu | | Scroll wheel | Zoom | -Any new 2D-projection explorer (including R3F top-down ortho variants) -should match this — left-pan, right-context — so muscle memory carries -across plugins. If you find yourself with a different default, that's -the bug, not a feature. +Left-pan, right-context is the reference. If you find yourself with a +different default, that's the bug, not a feature. ## R3F Top-Down 2D: OrbitControls, Not MapControls @@ -96,4 +96,4 @@ on a driver that does honour it. - ADR-702 (`docs/architecture/user-interfaces/ADR-702-unified-graph-rendering-engine.md`) — the unified-engine commitment - ADR-034 — the ExplorerPlugin contract every explorer follows -- `web/src/explorers/ForceGraph3D/scene/` — engine primitives (Scene, Nodes, Edges, Arrows, NodeLabels, EdgeLabels, useSim, useForceSim, useGpuForceSim, useDragHandler) +- `web/src/explorers/ForceGraph/scene/` — engine primitives (Scene, Nodes, Edges, Arrows, NodeLabels, EdgeLabels, useSim, useForceSim, useGpuForceSim, useDragHandler) diff --git a/web/src/explorers/ForceGraph3D/ForceGraph3D.tsx b/web/src/explorers/ForceGraph/ForceGraph.tsx similarity index 97% rename from web/src/explorers/ForceGraph3D/ForceGraph3D.tsx rename to web/src/explorers/ForceGraph/ForceGraph.tsx index aaffd8d79..fde344281 100644 --- a/web/src/explorers/ForceGraph3D/ForceGraph3D.tsx +++ b/web/src/explorers/ForceGraph/ForceGraph.tsx @@ -1,10 +1,11 @@ /** - * ForceGraph3D — Main Component + * ForceGraph — Main Component * * Mounts the r3f Canvas and the scene composition (instanced nodes + - * indexed edges, GPU force sim). Consumes the ExplorerPlugin contract - * from ADR-034; engine primitives come from the scene/ subdirectory - * per ADR-702. + * indexed edges, GPU force sim). Camera, sim axis count, and drag plane + * dispatch from `settings.projection` ('2D' or '3D'). Consumes the + * ExplorerPlugin contract from ADR-034; engine primitives come from the + * scene/ subdirectory per ADR-702. * * Node palette: built per-dataset from the ontologies present * (createOntologyColorScale). Default edge coloring is by relationship @@ -17,7 +18,7 @@ import { Canvas } from '@react-three/fiber'; import * as THREE from 'three'; import { Flame } from 'lucide-react'; import type { ExplorerProps } from '../../types/explorer'; -import type { ForceGraph3DData, ForceGraph3DSettings } from './types'; +import type { ForceGraphData, ForceGraphSettings } from './types'; import { Scene } from './scene/Scene'; import type { NodeInfoData } from './scene/NodeInfoOverlay'; import { simBackend } from './scene/useSim'; @@ -33,9 +34,9 @@ import type { GraphData } from '../../types/graph'; import { useThemeStore } from '../../store/themeStore'; -/** ForceGraph3D — r3f Canvas + scene composition. @verified c17bbeb9 */ -export const ForceGraph3D: React.FC< - ExplorerProps +/** ForceGraph — r3f Canvas + scene composition. @verified c17bbeb9 */ +export const ForceGraph: React.FC< + ExplorerProps > = ({ data, settings, onSettingsChange, onNodeClick, onSendToReports, className }) => { const [selectedId, setSelectedId] = useState(null); const [hoveredId, setHoveredId] = useState(null); @@ -179,7 +180,7 @@ export const ForceGraph3D: React.FC< // API-provided category → fallback. Keeping the chain in sync means the // shared visibleEdgeCategories store stays consistent across explorers. const edgeCategory = useCallback( - (e: ForceGraph3DData['edges'][number]): string => { + (e: ForceGraphData['edges'][number]): string => { let cat = vocabStore.getCategory(e.type); if (!cat) cat = e.source?.category; if (!cat || cat === 'default') cat = 'Uncategorized'; @@ -469,7 +470,7 @@ export const ForceGraph3D: React.FC< style={{ width: '240px' }} >
- ForceGraph3D + Force Graph > = ({ +export const SettingsPanel: React.FC> = ({ settings, onChange, }) => { @@ -38,11 +38,11 @@ export const SettingsPanel: React.FC> = return next; }); - const updatePhysics = (patch: Partial) => + const updatePhysics = (patch: Partial) => onChange({ ...settings, physics: { ...settings.physics, ...patch } }); - const updateVisual = (patch: Partial) => + const updateVisual = (patch: Partial) => onChange({ ...settings, visual: { ...settings.visual, ...patch } }); - const updateInteraction = (patch: Partial) => + const updateInteraction = (patch: Partial) => onChange({ ...settings, interaction: { ...settings.interaction, ...patch } }); const sectionHeader = (id: Section, title: string) => ( @@ -100,7 +100,7 @@ export const SettingsPanel: React.FC> = className="flex-[2] bg-card border border-border rounded px-1 py-0.5 text-xs" value={settings.projection} onChange={(e) => - onChange({ ...settings, projection: e.target.value as ForceGraph3DSettings['projection'] }) + onChange({ ...settings, projection: e.target.value as ForceGraphSettings['projection'] }) } > @@ -206,7 +206,7 @@ export const SettingsPanel: React.FC> = className="flex-[2] bg-card border border-border rounded px-1 py-0.5 text-xs" value={settings.visual.nodeColorBy} onChange={(e) => - updateVisual({ nodeColorBy: e.target.value as ForceGraph3DSettings['visual']['nodeColorBy'] }) + updateVisual({ nodeColorBy: e.target.value as ForceGraphSettings['visual']['nodeColorBy'] }) } > @@ -220,7 +220,7 @@ export const SettingsPanel: React.FC> = className="flex-[2] bg-card border border-border rounded px-1 py-0.5 text-xs" value={settings.visual.edgeColorBy} onChange={(e) => - updateVisual({ edgeColorBy: e.target.value as ForceGraph3DSettings['visual']['edgeColorBy'] }) + updateVisual({ edgeColorBy: e.target.value as ForceGraphSettings['visual']['edgeColorBy'] }) } > diff --git a/web/src/explorers/ForceGraph3D/dataTransformer.ts b/web/src/explorers/ForceGraph/dataTransformer.ts similarity index 92% rename from web/src/explorers/ForceGraph3D/dataTransformer.ts rename to web/src/explorers/ForceGraph/dataTransformer.ts index 53c46be05..de2e1646f 100644 --- a/web/src/explorers/ForceGraph3D/dataTransformer.ts +++ b/web/src/explorers/ForceGraph/dataTransformer.ts @@ -1,5 +1,5 @@ /** - * Data transformer — RawGraphData → ForceGraph3DData + * Data transformer — RawGraphData → ForceGraphData * * Converts the store's raw node/link records into the engine's * {EngineNode[], EngineEdge[]} shape. Degree is computed here (not in the @@ -8,10 +8,10 @@ */ import type { RawGraphData } from '../../utils/cypherResultMapper'; -import type { EngineNode, EngineEdge, ForceGraph3DData } from './types'; +import type { EngineNode, EngineEdge, ForceGraphData } from './types'; /** Transform store graph data to the engine's node/edge shape. @verified c17bbeb9 */ -export function transformForEngine(apiData: RawGraphData): ForceGraph3DData { +export function transformForEngine(apiData: RawGraphData): ForceGraphData { const apiNodes = apiData.nodes || []; const apiLinks = apiData.links || []; diff --git a/web/src/explorers/ForceGraph3D/index.ts b/web/src/explorers/ForceGraph/index.ts similarity index 81% rename from web/src/explorers/ForceGraph3D/index.ts rename to web/src/explorers/ForceGraph/index.ts index 3d3766451..ad5d8accb 100644 --- a/web/src/explorers/ForceGraph3D/index.ts +++ b/web/src/explorers/ForceGraph/index.ts @@ -11,13 +11,13 @@ import { Network } from 'lucide-react'; import type { ExplorerPlugin } from '../../types/explorer'; -import { ForceGraph3D } from './ForceGraph3D'; +import { ForceGraph } from './ForceGraph'; import { SettingsPanel } from './SettingsPanel'; -import type { ForceGraph3DData, ForceGraph3DSettings } from './types'; +import type { ForceGraphData, ForceGraphSettings } from './types'; import { DEFAULT_SETTINGS } from './types'; import { transformForEngine } from './dataTransformer'; -export const ForceGraphExplorer: ExplorerPlugin = { +export const ForceGraphExplorer: ExplorerPlugin = { config: { id: 'force-graph', type: 'force-graph', @@ -26,7 +26,7 @@ export const ForceGraphExplorer: ExplorerPlugin to drive their respective - * rendering paths (canvas fillStyle for 2D, instanced color buffer for V2). - * - * Inputs are minimal-shape adapters so callers don't need to share node - * or link types — 2D uses d3-style links (source/target as string|object), - * V2 uses engine edges (from/to as string). + * Callers feed `mode` from settings.visual.nodeColorBy and consume the + * returned Map to drive the instanced color buffer on the + * unified r3f engine. Inputs are minimal-shape adapters so the helper + * can also serve future explorers that share the engine but carry their + * own node/edge shape. */ import * as d3 from 'd3'; -/** Node-color dimension. Mirrors ForceGraph2D's NodeColorMode union. */ +/** Node-color dimension. */ export type NodeColorMode = 'ontology' | 'degree' | 'centrality'; export interface NodeColorInput { diff --git a/web/src/explorers/common/useGraphContextMenu.ts b/web/src/explorers/common/useGraphContextMenu.ts index 1c51d6b90..6be6f5096 100644 --- a/web/src/explorers/common/useGraphContextMenu.ts +++ b/web/src/explorers/common/useGraphContextMenu.ts @@ -87,8 +87,7 @@ export interface GraphContextMenuCallbacks { * Follow / add-adjacent / remove / travel-path delegate to * `useExplorationActions` — the single writer for graph-mutating * operations. The wrappers here add UX concerns layered on top (an - * `alert` dialog on failure) and preserve the historic return shape so - * existing call sites in 2D / 3D-V1 / 3D-V2 don't need to change. + * `alert` dialog on failure). * * Send-to-polarity and send-path-to-reports stay here because they're * pure navigation / report creation, not graph-mutating. diff --git a/web/src/explorers/index.ts b/web/src/explorers/index.ts index e277a448c..23c1661f9 100644 --- a/web/src/explorers/index.ts +++ b/web/src/explorers/index.ts @@ -5,9 +5,9 @@ */ export * from './registry'; -export { ForceGraphExplorer } from './ForceGraph3D'; +export { ForceGraphExplorer } from './ForceGraph'; export { DocumentExplorerPlugin } from './DocumentExplorer'; // Import explorers to trigger auto-registration -import './ForceGraph3D'; +import './ForceGraph'; import './DocumentExplorer';