From 1de90ed361e934e40b07eb2e94c162e6f376025f Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Feb 2026 22:40:55 -0500 Subject: [PATCH 01/10] feat: scaffold dependency graph overhaul with sigma.js + graphology - Remove reactflow, dagre, @types/dagre - Install sigma, graphology, graphology-layout-forceatlas2, graphology-communities-louvain, @react-sigma/core - Create new DependencyGraph structure: GraphView/, MatrixView/, types.ts - Build useGraphData hook (API -> graphology Graph + ForceAtlas2 + Louvain) - Build useMatrixData hook (API -> adjacency matrix + cycle detection) - Rewrite index.tsx with Graph/Matrix tab switcher + StatsBar - Wire existing ImpactPanel + useImpactAnalysis (unchanged) - Placeholder GraphView and MatrixView components (to be implemented) Closes OPE-41, OPE-42, OPE-43, OPE-44 --- frontend/bun.lock | 141 +--- frontend/package.json | 9 +- .../DependencyGraph/GraphView/index.tsx | 17 + .../DependencyGraph/GraphView/useGraphData.ts | 123 ++++ .../DependencyGraph/MatrixView/index.tsx | 17 + .../MatrixView/useMatrixData.ts | 94 +++ .../src/components/DependencyGraph/index.tsx | 684 +++--------------- .../src/components/DependencyGraph/types.ts | 46 ++ 8 files changed, 444 insertions(+), 687 deletions(-) create mode 100644 frontend/src/components/DependencyGraph/GraphView/index.tsx create mode 100644 frontend/src/components/DependencyGraph/GraphView/useGraphData.ts create mode 100644 frontend/src/components/DependencyGraph/MatrixView/index.tsx create mode 100644 frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts create mode 100644 frontend/src/components/DependencyGraph/types.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 82a3651..2e56bad 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -19,22 +19,25 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@react-sigma/core": "^5.0.6", "@supabase/supabase-js": "^2.39.0", "@tanstack/react-query": "^5.90.12", - "@types/dagre": "^0.7.53", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dagre": "^0.8.5", "framer-motion": "^12.29.0", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-types": "^0.24.8", "lucide-react": "^0.554.0", "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.12.0", "react-syntax-highlighter": "^16.1.0", - "reactflow": "^11.11.4", + "sigma": "^3.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", @@ -254,17 +257,7 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@reactflow/background": ["@reactflow/background@11.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA=="], - - "@reactflow/controls": ["@reactflow/controls@11.2.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw=="], - - "@reactflow/core": ["@reactflow/core@11.11.4", "", { "dependencies": { "@types/d3": "^7.4.0", "@types/d3-drag": "^3.0.1", "@types/d3-selection": "^3.0.3", "@types/d3-zoom": "^3.0.1", "classcat": "^5.0.3", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q=="], - - "@reactflow/minimap": ["@reactflow/minimap@11.7.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "@types/d3-selection": "^3.0.3", "@types/d3-zoom": "^3.0.1", "classcat": "^5.0.3", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ=="], - - "@reactflow/node-resizer": ["@reactflow/node-resizer@2.2.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.4", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA=="], - - "@reactflow/node-toolbar": ["@reactflow/node-toolbar@1.3.14", "", { "dependencies": { "@reactflow/core": "11.11.4", "classcat": "^5.0.3", "zustand": "^4.4.1" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ=="], + "@react-sigma/core": ["@react-sigma/core@5.0.6", "", { "peerDependencies": { "graphology": "^0.26.0", "react": "^18.0.0 || ^19.0.0", "sigma": "^3.0.2" } }, "sha512-Xu2qXyvDZIhmvGC1n8d7Kcxm5Ntcz4HbPIM7CPDD2e4h3s/oxVpVPX7wtsNreJRRPj9mK+3oqB6SWXNI4mTqVg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -342,74 +335,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], - - "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], - - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], - - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], - - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], - - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], - - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], - - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], - - "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], - - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], - - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], - - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], - - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], - - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], - - "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], - - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - - "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], - - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - - "@types/dagre": ["@types/dagre@0.7.53", "", {}, "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], @@ -464,8 +391,6 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -482,26 +407,6 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre": ["dagre@0.8.5", "", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -518,6 +423,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -544,7 +451,17 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="], + "graphology": ["graphology@0.26.0", "", { "dependencies": { "events": "^3.3.0" }, "peerDependencies": { "graphology-types": ">=0.24.0" } }, "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg=="], + + "graphology-communities-louvain": ["graphology-communities-louvain@2.0.2", "", { "dependencies": { "graphology-indices": "^0.17.0", "graphology-utils": "^2.4.4", "mnemonist": "^0.39.0", "pandemonium": "^2.4.1" }, "peerDependencies": { "graphology-types": ">=0.19.0" } }, "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA=="], + + "graphology-indices": ["graphology-indices@0.17.0", "", { "dependencies": { "graphology-utils": "^2.4.2", "mnemonist": "^0.39.0" }, "peerDependencies": { "graphology-types": ">=0.20.0" } }, "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ=="], + + "graphology-layout-forceatlas2": ["graphology-layout-forceatlas2@0.10.1", "", { "dependencies": { "graphology-utils": "^2.1.0" }, "peerDependencies": { "graphology-types": ">=0.19.0" } }, "sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ=="], + + "graphology-types": ["graphology-types@0.24.8", "", {}, "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q=="], + + "graphology-utils": ["graphology-utils@2.5.2", "", { "peerDependencies": { "graphology-types": ">=0.23.0" } }, "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -588,8 +505,6 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], @@ -602,6 +517,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mnemonist": ["mnemonist@0.39.8", "", { "dependencies": { "obliterator": "^2.0.1" } }, "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ=="], + "motion-dom": ["motion-dom@12.34.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q=="], "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], @@ -622,6 +539,10 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="], + + "pandemonium": ["pandemonium@2.4.1", "", { "dependencies": { "mnemonist": "^0.39.2" } }, "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -672,8 +593,6 @@ "react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="], - "reactflow": ["reactflow@11.11.4", "", { "dependencies": { "@reactflow/background": "11.3.14", "@reactflow/controls": "11.2.14", "@reactflow/core": "11.11.4", "@reactflow/minimap": "11.7.14", "@reactflow/node-resizer": "2.2.14", "@reactflow/node-toolbar": "1.3.14" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og=="], - "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -694,6 +613,8 @@ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "sigma": ["sigma@3.0.2", "", { "dependencies": { "events": "^3.3.0", "graphology-utils": "^2.5.2" } }, "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -742,8 +663,6 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["immer"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], @@ -777,9 +696,5 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "tinyglobby/fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], } } diff --git a/frontend/package.json b/frontend/package.json index 5c75245..b887acf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,22 +24,25 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@react-sigma/core": "^5.0.6", "@supabase/supabase-js": "^2.39.0", "@tanstack/react-query": "^5.90.12", - "@types/dagre": "^0.7.53", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dagre": "^0.8.5", "framer-motion": "^12.29.0", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", + "graphology-layout-forceatlas2": "^0.10.1", + "graphology-types": "^0.24.8", "lucide-react": "^0.554.0", "next-themes": "^0.4.6", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.12.0", "react-syntax-highlighter": "^16.1.0", - "reactflow": "^11.11.4", + "sigma": "^3.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7" diff --git a/frontend/src/components/DependencyGraph/GraphView/index.tsx b/frontend/src/components/DependencyGraph/GraphView/index.tsx new file mode 100644 index 0000000..ce203b0 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -0,0 +1,17 @@ +// Sigma.js WebGL graph rendering view +// TODO: OPE-45 -- full implementation with hover/click interactions + +import type { DependencyApiResponse } from '../types' + +interface GraphViewProps { + data: DependencyApiResponse + onSelectFile?: (filePath: string) => void +} + +export function GraphView({ data, onSelectFile }: GraphViewProps) { + return ( +
+ GraphView placeholder -- {data.nodes?.length || 0} nodes ready for Sigma.js rendering +
+ ) +} diff --git a/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts new file mode 100644 index 0000000..4e31c84 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts @@ -0,0 +1,123 @@ +// Transforms API dependency response into a graphology Graph instance +// with ForceAtlas2 layout positions and Louvain community colors + +import { useMemo } from 'react' +import Graph from 'graphology' +import forceAtlas2 from 'graphology-layout-forceatlas2' +import louvain from 'graphology-communities-louvain' +import type { DependencyApiResponse } from '../types' + +// Community color palette -- distinct, accessible on dark backgrounds +const COMMUNITY_COLORS = [ + '#6366f1', // indigo + '#22c55e', // green + '#f59e0b', // amber + '#ec4899', // pink + '#06b6d4', // cyan + '#f97316', // orange + '#8b5cf6', // violet + '#14b8a6', // teal + '#ef4444', // red + '#84cc16', // lime +] + +function getDirectory(filePath: string): string { + const parts = filePath.split('/') + return parts.length > 1 ? parts.slice(0, -1).join('/') : '.' +} + +function getRiskLevel(dependentCount: number): 'low' | 'med' | 'high' { + if (dependentCount >= 4) return 'high' + if (dependentCount >= 1) return 'med' + return 'low' +} + +export function useGraphData(apiData: DependencyApiResponse | undefined) { + return useMemo(() => { + if (!apiData?.nodes?.length) return null + + const graph = new Graph({ type: 'directed' }) + + // Build in-degree map to know dependents count per node + const inDegree: Record = {} + for (const edge of apiData.edges) { + inDegree[edge.target] = (inDegree[edge.target] || 0) + 1 + } + + // Add nodes + for (const node of apiData.nodes) { + const dependents = inDegree[node.id] || 0 + const imports = node.import_count ?? node.imports ?? 0 + + graph.addNode(node.id, { + label: node.label || node.id.split('/').pop() || node.id, + size: Math.max(4, Math.min(20, 4 + dependents * 2)), + directory: getDirectory(node.id), + imports, + dependents, + riskLevel: getRiskLevel(dependents), + language: node.language || 'unknown', + // x/y will be set by ForceAtlas2 + x: Math.random() * 100, + y: Math.random() * 100, + }) + } + + // Add edges (skip if source or target missing) + for (const edge of apiData.edges) { + if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) { + // Avoid duplicate edges + if (!graph.hasEdge(edge.source, edge.target)) { + graph.addEdge(edge.source, edge.target, { + weight: 1, + type: 'arrow', + }) + } + } + } + + // Run Louvain community detection for cluster coloring + try { + const communities = louvain(graph) + const communityIds = [...new Set(Object.values(communities))] + + graph.forEachNode((node) => { + const communityIndex = communityIds.indexOf(communities[node]) + graph.setNodeAttribute( + node, + 'color', + COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length] + ) + graph.setNodeAttribute(node, 'community', communities[node]) + }) + } catch { + // Louvain can fail on disconnected graphs -- fall back to directory-based coloring + const directories = [...new Set(graph.mapNodes((_, attrs) => attrs.directory))] + graph.forEachNode((node, attrs) => { + const dirIndex = directories.indexOf(attrs.directory) + graph.setNodeAttribute( + node, + 'color', + COMMUNITY_COLORS[dirIndex % COMMUNITY_COLORS.length] + ) + }) + } + + // Run ForceAtlas2 for layout positions + // Use synchronous version with fixed iterations for deterministic layout + forceAtlas2.assign(graph, { + iterations: 300, + settings: { + gravity: 1, + scalingRatio: 10, + barnesHutOptimize: graph.order > 100, + barnesHutTheta: 0.5, + slowDown: 5, + strongGravityMode: false, + adjustSizes: true, + }, + }) + + return graph + }, [apiData]) +} diff --git a/frontend/src/components/DependencyGraph/MatrixView/index.tsx b/frontend/src/components/DependencyGraph/MatrixView/index.tsx new file mode 100644 index 0000000..1a7bc68 --- /dev/null +++ b/frontend/src/components/DependencyGraph/MatrixView/index.tsx @@ -0,0 +1,17 @@ +// Dependency Structure Matrix (DSM) view +// TODO: OPE-46 -- full implementation with cycle detection + directory grouping + +import type { DependencyApiResponse } from '../types' + +interface MatrixViewProps { + data: DependencyApiResponse + onSelectFile?: (filePath: string) => void +} + +export function MatrixView({ data, onSelectFile }: MatrixViewProps) { + return ( +
+ MatrixView placeholder -- {data.nodes?.length || 0} files ready for DSM rendering +
+ ) +} diff --git a/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts b/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts new file mode 100644 index 0000000..253ac74 --- /dev/null +++ b/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts @@ -0,0 +1,94 @@ +// Transforms API dependency response into a 2D adjacency matrix +// for the Dependency Structure Matrix (DSM) view + +import { useMemo } from 'react' +import type { DependencyApiResponse, MatrixData } from '../types' + +function getDirectory(filePath: string): string { + const parts = filePath.split('/') + return parts.length > 1 ? parts.slice(0, -1).join('/') : '.' +} + +function getShortLabel(filePath: string): string { + return filePath.split('/').pop() || filePath +} + +export function useMatrixData(apiData: DependencyApiResponse | undefined): MatrixData | null { + return useMemo(() => { + if (!apiData?.nodes?.length) return null + + // Sort files by directory so same-directory files are adjacent + const sortedFiles = [...apiData.nodes] + .map((n) => n.id) + .sort((a, b) => { + const dirA = getDirectory(a) + const dirB = getDirectory(b) + if (dirA !== dirB) return dirA.localeCompare(dirB) + return a.localeCompare(b) + }) + + // Build index lookup: file path -> matrix index + const indexMap = new Map() + sortedFiles.forEach((file, idx) => { + indexMap.set(file, idx) + }) + + const size = sortedFiles.length + + // Build adjacency matrix + // matrix[row][col] = number of imports from row -> col + const matrix: number[][] = Array.from({ length: size }, () => + new Array(size).fill(0) + ) + + for (const edge of apiData.edges) { + const sourceIdx = indexMap.get(edge.source) + const targetIdx = indexMap.get(edge.target) + if (sourceIdx !== undefined && targetIdx !== undefined) { + matrix[sourceIdx][targetIdx] += 1 + } + } + + // Detect circular dependencies: both directions have imports + const cycles: [number, number][] = [] + for (let i = 0; i < size; i++) { + for (let j = i + 1; j < size; j++) { + if (matrix[i][j] > 0 && matrix[j][i] > 0) { + cycles.push([i, j]) + } + } + } + + // Build directory grouping and find separator positions + const directories = new Map() + const directorySeparators: number[] = [] + let prevDir = '' + + sortedFiles.forEach((file, idx) => { + const dir = getDirectory(file) + if (!directories.has(dir)) { + directories.set(dir, []) + } + directories.get(dir)!.push(idx) + + if (dir !== prevDir && idx > 0) { + directorySeparators.push(idx) + } + prevDir = dir + }) + + const totalDeps = apiData.edges.length + const totalCycles = cycles.length + + return { + labels: sortedFiles, + shortLabels: sortedFiles.map(getShortLabel), + matrix, + directories, + directorySeparators, + cycles, + totalDeps, + totalCycles, + } + }, [apiData]) +} diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx index 6eb0044..6096e0b 100644 --- a/frontend/src/components/DependencyGraph/index.tsx +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -1,26 +1,16 @@ -import { useEffect, useState, useCallback, useMemo, useRef } from 'react' -import ReactFlow, { - Controls, - Background, - useNodesState, - useEdgesState, - useReactFlow, - ReactFlowProvider, -} from 'reactflow' -import type { Node, Edge } from 'reactflow' -import dagre from 'dagre' -import { useTheme } from 'next-themes' -import { FileCode2, GitBranch, Navigation, AlertTriangle, FolderTree } from 'lucide-react' -import 'reactflow/dist/style.css' +// DependencyGraph -- main entry point +// Renders stats bar, view toggle (Graph | Matrix), and the active view -import { useDependencyGraph } from '../../hooks/useCachedQuery' +import { useState } from 'react' +import { GitFork, LayoutGrid, FileCode2, Navigation, AlertTriangle } from 'lucide-react' +import { GraphView } from './GraphView' +import { MatrixView } from './MatrixView' +import { ImpactPanel } from './ImpactPanel' +import { useImpactAnalysis } from './hooks/useImpactAnalysis' import { DependencyGraphSkeleton } from '../ui/Skeleton' import { Card, CardContent } from '@/components/ui/card' -import { useImpactAnalysis, type ImpactResult } from './hooks/useImpactAnalysis' -import { GraphNode, type GraphNodeData } from './GraphNode' -import { DirectoryNode, type DirectoryNodeData } from './DirectoryNode' -import { ImpactPanel } from './ImpactPanel' -import { GraphToolbar } from './GraphToolbar' +import { useDependencyGraph } from '../../hooks/useCachedQuery' +import type { DependencyApiResponse } from './types' interface DependencyGraphProps { repoId: string @@ -28,578 +18,130 @@ interface DependencyGraphProps { apiKey: string } -const nodeTypes = { - custom: GraphNode, - directory: DirectoryNode, -} - -const LAYOUT_CONFIG = { - rankdir: 'LR', - ranksep: 100, - nodesep: 60, - nodeWidth: 200, - nodeHeight: 70, -} - -const DEFAULT_VISIBLE_COUNT = 15 +type ViewMode = 'graph' | 'matrix' -function getLayoutedElements(nodes: Node[], edges: Edge[]) { - // Guard: if no nodes, return empty - if (nodes.length === 0) { - return { nodes: [], edges: [] } - } +function StatsBar({ data }: { data: DependencyApiResponse | undefined }) { + if (!data) return null - const dagreGraph = new dagre.graphlib.Graph() - dagreGraph.setDefaultEdgeLabel(() => ({})) - dagreGraph.setGraph({ - rankdir: LAYOUT_CONFIG.rankdir, - ranksep: LAYOUT_CONFIG.ranksep, - nodesep: LAYOUT_CONFIG.nodesep, - }) + const totalFiles = data.total_files ?? data.nodes?.length ?? 0 + const totalDeps = data.total_dependencies ?? data.edges?.length ?? 0 + const entryPoints = data.nodes?.filter( + (n) => (n.import_count ?? n.imports ?? 0) === 0 + ).length ?? 0 + const criticalFiles = data.metrics?.most_critical_files?.filter( + (f) => f.dependents >= 5 + ).length ?? 0 - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { - width: LAYOUT_CONFIG.nodeWidth, - height: LAYOUT_CONFIG.nodeHeight - }) - }) + const stats = [ + { label: 'Total Files', value: totalFiles, icon: FileCode2 }, + { label: 'Dependencies', value: totalDeps, icon: GitFork }, + { label: 'Entry Points', value: entryPoints, icon: Navigation }, + { label: 'Critical Files', value: criticalFiles, icon: AlertTriangle }, + ] - // Only add edges where both source and target exist in nodes - const nodeIds = new Set(nodes.map(n => n.id)) - edges.forEach((edge) => { - if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { - dagreGraph.setEdge(edge.source, edge.target) - } - }) - - dagre.layout(dagreGraph) - - const layoutedNodes = nodes.map((node) => { - const nodeWithPosition = dagreGraph.node(node.id) - // Guard: if dagre failed to position node, use fallback - const x = nodeWithPosition?.x ?? 0 - const y = nodeWithPosition?.y ?? 0 - return { - ...node, - position: { - x: x - LAYOUT_CONFIG.nodeWidth / 2, - y: y - LAYOUT_CONFIG.nodeHeight / 2, - }, - } - }) - - return { nodes: layoutedNodes, edges } -} - -const getEdgeStyle = (state: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing', isDark: boolean) => { - const styles = { - default: { stroke: isDark ? '#52525b' : '#a1a1aa', strokeWidth: 1, opacity: 0.6 }, - highlighted: { stroke: '#6366f1', strokeWidth: 2, opacity: 1 }, - dimmed: { stroke: isDark ? '#27272a' : '#e4e4e7', strokeWidth: 1, opacity: 0.3 }, - incoming: { stroke: '#f43f5e', strokeWidth: 2, opacity: 1 }, - outgoing: { stroke: '#6366f1', strokeWidth: 2, opacity: 1 }, - } - return styles[state] + return ( +
+ {stats.map((stat) => ( + + +
+ + {stat.label} +
+
{stat.value}
+
+
+ ))} +
+ ) } -// Get directory path from file path -function getDirPath(filePath: string): string { - const parts = filePath.split('/') - parts.pop() - return parts.join('/') || '/' -} +function ViewToggle({ + active, + onChange, +}: { + active: ViewMode + onChange: (mode: ViewMode) => void +}) { + const tabs = [ + { id: 'graph' as const, label: 'Graph', icon: GitFork }, + { id: 'matrix' as const, label: 'Matrix', icon: LayoutGrid }, + ] -// Get max risk from array -function getMaxRisk(risks: Array<'low' | 'medium' | 'high' | 'critical'>): 'low' | 'medium' | 'high' | 'critical' { - const priority = { critical: 4, high: 3, medium: 2, low: 1 } - return risks.reduce((max, risk) => priority[risk] > priority[max] ? risk : max, 'low' as const) + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ) } -function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) { - const [nodes, setNodes, onNodesChange] = useNodesState([]) - const [edges, setEdges, onEdgesChange] = useEdgesState([]) - const [selectedNodeId, setSelectedNodeId] = useState(null) - const [hoveredFileId, setHoveredFileId] = useState(null) - const [showAll, setShowAll] = useState(false) - const [showTests, setShowTests] = useState(true) - const [clusterByDir, setClusterByDir] = useState(false) - const [expandedDirs, setExpandedDirs] = useState>(new Set()) - const [rawGraphData, setRawGraphData] = useState(null) - const [renderKey, setRenderKey] = useState(0) // Force re-render key - - const { fitView } = useReactFlow() - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - - const { data, isLoading } = useDependencyGraph({ repoId, apiKey }) - const impact = useImpactAnalysis(rawGraphData) - - useEffect(() => { - if (data) setRawGraphData(data) - }, [data]) - - // Handle tab visibility changes - force re-render when tab becomes visible - useEffect(() => { - const handleVisibilityChange = () => { - if (document.visibilityState === 'visible') { - // Force re-render by updating key - setRenderKey(k => k + 1) - // Also trigger fitView after a short delay - setTimeout(() => { - fitView({ padding: 0.2, duration: 200 }) - }, 100) - } - } - - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => document.removeEventListener('visibilitychange', handleVisibilityChange) - }, [fitView]) - - const visibleNodeIds = useMemo(() => { - if (!rawGraphData || !impact.isReady) return new Set() - - let nodeIds: string[] = rawGraphData.nodes.map((n: any) => n.id) - - if (!showTests) { - nodeIds = nodeIds.filter((id: string) => { - const fileName = id.split('/').pop() || '' - const pathLower = id.toLowerCase() - // Filter out test files by filename pattern OR by being in tests/ directory - const isTestFile = fileName.includes('.test.') || - fileName.includes('_test.') || - fileName.includes('.spec.') || - fileName.startsWith('test_') || - pathLower.includes('/tests/') || - pathLower.startsWith('tests/') - return !isTestFile - }) - } - - if (!showAll) { - const topFiles = impact.getTopFiles(DEFAULT_VISIBLE_COUNT) - nodeIds = nodeIds.filter((id: string) => topFiles.includes(id)) - } - - return new Set(nodeIds) - }, [rawGraphData, impact.isReady, impact.fileMetrics, showAll, showTests]) - - const selectedImpact = useMemo((): ImpactResult | null => { - if (!selectedNodeId || !impact.isReady) return null - // For directory nodes, we don't show impact panel - if (selectedNodeId.startsWith('dir:')) return null - return impact.getDependents(selectedNodeId) - }, [selectedNodeId, impact]) - - // Build clustered graph data - const clusteredData = useMemo(() => { - if (!clusterByDir || !rawGraphData || !impact.isReady) return null - - // Group files by directory - const dirFiles = new Map() - visibleNodeIds.forEach(fileId => { - const dirPath = getDirPath(fileId) - if (!dirFiles.has(dirPath)) dirFiles.set(dirPath, []) - dirFiles.get(dirPath)!.push(fileId) - }) - - // Create directory nodes for collapsed dirs - const dirNodes: Array<{ id: string; data: DirectoryNodeData }> = [] - const visibleFiles = new Set() - - dirFiles.forEach((files, dirPath) => { - const isExpanded = expandedDirs.has(dirPath) - - // Calculate metrics for this directory - const metrics = files.map(f => impact.getFileMetrics(f)).filter(Boolean) - const totalDeps = metrics.reduce((sum, m) => sum + (m?.dependentCount || 0), 0) - const risks = metrics.map(m => m?.riskLevel || 'low') as Array<'low' | 'medium' | 'high' | 'critical'> - - // Always create directory node so user can collapse - dirNodes.push({ - id: `dir:${dirPath}`, - data: { - label: dirPath.split('/').pop() || dirPath, - fullPath: dirPath, - fileCount: files.length, - totalDependents: totalDeps, - maxRisk: getMaxRisk(risks), - isExpanded, - state: 'default', - } - }) - - // When expanded, also show individual files - if (isExpanded) { - files.forEach(f => visibleFiles.add(f)) - } - }) - - // Build edges between dirs or files - const edgeSet = new Set() - rawGraphData.edges.forEach((edge: any) => { - const sourceVisible = visibleFiles.has(edge.source) - const targetVisible = visibleFiles.has(edge.target) - const sourceDirId = `dir:${getDirPath(edge.source)}` - const targetDirId = `dir:${getDirPath(edge.target)}` - - let source = sourceVisible ? edge.source : (visibleNodeIds.has(edge.source) ? sourceDirId : null) - let target = targetVisible ? edge.target : (visibleNodeIds.has(edge.target) ? targetDirId : null) +export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps) { + const [viewMode, setViewMode] = useState('graph') + const [selectedFile, setSelectedFile] = useState(null) - if (source && target && source !== target) { - edgeSet.add(`${source}|${target}`) - } - }) - - return { dirNodes, visibleFiles, edges: Array.from(edgeSet).map(e => e.split('|')) } - }, [clusterByDir, rawGraphData, impact.isReady, visibleNodeIds, expandedDirs]) - - useEffect(() => { - if (!rawGraphData || !impact.isReady) return - - let flowNodes: Node[] = [] - let flowEdges: Edge[] = [] - - // Only use cluster mode if we have clustered data ready - const useClusterMode = clusterByDir && clusteredData && clusteredData.dirNodes.length > 0 - - if (useClusterMode) { - // Safety: only apply selection highlighting if the selected node is actually visible - const effectiveSelectedIdCluster = selectedNodeId && - (clusteredData!.visibleFiles.has(selectedNodeId) || selectedNodeId.startsWith('dir:')) - ? selectedNodeId : null - - // Add directory nodes - clusteredData!.dirNodes.forEach(dir => { - flowNodes.push({ - id: dir.id, - type: 'directory', - data: dir.data, - position: { x: 0, y: 0 }, - }) - }) - - // Add visible file nodes - rawGraphData.nodes - .filter((node: any) => clusteredData!.visibleFiles.has(node.id)) - .forEach((node: any) => { - const fileName = node.label || node.id.split('/').pop() - const metrics = impact.getFileMetrics(node.id) - - // Simplified state - only highlight, don't dim - let state: GraphNodeData['state'] = 'default' - if (effectiveSelectedIdCluster === node.id) state = 'selected' - else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct' - else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive' - // Don't dim - keep as default - - flowNodes.push({ - id: node.id, - type: 'custom', - data: { - label: fileName, - fullPath: node.id, - language: node.language || 'unknown', - dependentCount: metrics?.dependentCount || 0, - importCount: metrics?.importCount || 0, - riskLevel: metrics?.riskLevel || 'low', - isEntryPoint: metrics?.isEntryPoint || false, - state, - }, - position: { x: 0, y: 0 }, - }) - }) - - // Add edges - clusteredData!.edges.forEach(([source, target]) => { - flowEdges.push({ - id: `${source}-${target}`, - source, - target, - style: getEdgeStyle('default', isDark), - }) - }) - } else { - // Non-clustered mode (original logic) - // Safety: only apply selection highlighting if the selected node is actually visible - const effectiveSelectedId = selectedNodeId && visibleNodeIds.has(selectedNodeId) ? selectedNodeId : null - - flowNodes = rawGraphData.nodes - .filter((node: any) => visibleNodeIds.has(node.id)) - .map((node: any) => { - const fileName = node.label || node.id.split('/').pop() - const metrics = impact.getFileMetrics(node.id) - - // Simplified state - only highlight selected and dependents, don't dim others - let state: GraphNodeData['state'] = 'default' - if (effectiveSelectedId) { - if (node.id === effectiveSelectedId) state = 'selected' - else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct' - else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive' - // Don't dim - keep as default for visibility - } - - return { - id: node.id, - type: 'custom', - data: { - label: fileName, - fullPath: node.id, - language: node.language || 'unknown', - dependentCount: metrics?.dependentCount || 0, - importCount: metrics?.importCount || 0, - riskLevel: metrics?.riskLevel || 'low', - isEntryPoint: metrics?.isEntryPoint || false, - state, - }, - position: { x: 0, y: 0 }, - } - }) - - flowEdges = rawGraphData.edges - .filter((edge: any) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) - .map((edge: any) => { - // Simplified - only highlight connected edges, don't dim others - let edgeState: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing' = 'default' - if (effectiveSelectedId) { - if (edge.source === effectiveSelectedId) edgeState = 'outgoing' - else if (edge.target === effectiveSelectedId) edgeState = 'incoming' - // Don't dim - keep as default - } - - return { - id: `${edge.source}-${edge.target}`, - source: edge.source, - target: edge.target, - style: getEdgeStyle(edgeState, isDark), - animated: edgeState === 'incoming', - } - }) - } - - const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(flowNodes, flowEdges) - setNodes(layoutedNodes) - setEdges(layoutedEdges) - }, [rawGraphData, impact.isReady, visibleNodeIds, selectedNodeId, selectedImpact, hoveredFileId, isDark, clusterByDir, clusteredData]) - - // Fit view when nodes change or panel opens/closes - useEffect(() => { - if (nodes.length > 0) { - const minZoom = nodes.length > 20 ? 0.5 : 0.3 - // Delay to allow container resize when panel opens/closes - setTimeout(() => fitView({ padding: 0.2, duration: 300, minZoom }), 150) - } - }, [nodes.length, selectedNodeId, showAll, showTests, clusterByDir, expandedDirs, fitView]) - - const handleNodeClick = useCallback((_: any, node: Node) => { - // Toggle directory expansion - if (node.id.startsWith('dir:')) { - const dirPath = node.id.replace('dir:', '') - setExpandedDirs(prev => { - const next = new Set(prev) - if (next.has(dirPath)) next.delete(dirPath) - else next.add(dirPath) - return next - }) - return - } - setSelectedNodeId(prev => prev === node.id ? null : node.id) - }, []) + const { data, isLoading } = useDependencyGraph({ + repoId, + apiKey, + enabled: true, + }) - const handlePaneClick = useCallback(() => { - setSelectedNodeId(null) - }, []) + // Client-side impact analysis using the full graph data + const { getDependents, getImports } = useImpactAnalysis(data ?? null) - const handlePanelFileClick = useCallback((fileId: string) => { - // Only select if the file is currently visible in the graph - // Otherwise clicking on a non-visible dependent breaks the view - if (!visibleNodeIds.has(fileId)) { - // If not visible, enable "show all" to make it visible first - if (!showAll) { - setShowAll(true) - // Small delay to let the nodes render, then select - setTimeout(() => { - setSelectedNodeId(fileId) - }, 100) - return - } - } - - setSelectedNodeId(fileId) - const node = nodes.find(n => n.id === fileId) - if (node) { - fitView({ nodes: [node], padding: 0.5, duration: 300 }) - } - }, [nodes, fitView, visibleNodeIds, showAll]) + // Compute impact for selected file + const selectedImpact = selectedFile ? getDependents(selectedFile) : null + const selectedFileName = selectedFile?.split('/').pop() ?? '' - const handleResetView = useCallback(() => { - setSelectedNodeId(null) - setExpandedDirs(new Set()) - const minZoom = nodes.length > 20 ? 0.5 : 0.3 - fitView({ padding: 0.2, duration: 300, minZoom }) - }, [fitView, nodes.length]) + if (isLoading) return - if (isLoading) { - return + if (!data?.nodes?.length) { + return ( +
+ No dependency data available. Try re-indexing the repository. +
+ ) } - const selectedNode = rawGraphData?.nodes.find((n: any) => n.id === selectedNodeId) - const selectedFileName = selectedNode?.label || selectedNodeId?.split('/').pop() || '' - const criticalCount = impact.fileMetrics.filter(f => f.riskLevel === 'critical' || f.riskLevel === 'high').length - const dirCount = clusterByDir && clusteredData ? clusteredData.dirNodes.length : 0 - return ( -
- {/* Metrics Bar */} -
- - -
- - Total Files -
-
- {rawGraphData?.nodes?.length || 0} -
-
-
- - -
- - Dependencies -
-
- {rawGraphData?.edges?.length || 0} -
-
-
- - -
- {clusterByDir ? : } - {clusterByDir ? 'Directories' : 'Entry Points'} -
-
- {clusterByDir ? dirCount : impact.entryPoints.length} -
-
-
- - -
- - Critical Files -
-
- {criticalCount} -
-
-
-
- - setShowAll(prev => !prev)} - onToggleTests={() => setShowTests(prev => !prev)} - onToggleCluster={() => { - setClusterByDir(prev => !prev) - setExpandedDirs(new Set()) - }} - onResetView={handleResetView} - /> - -
-
- - - - - - {/* Legend - positioned bottom-right to avoid Controls overlap */} - - -
Legend
-
- {clusterByDir && ( -
-
- Directory (click to expand) -
- )} -
-
- Selected -
-
-
- Direct dependent -
-
-
- Transitive dependent -
-
-
- Entry point -
-
-
- {clusterByDir ? 'Click folder to expand' : 'Click node to analyze impact'} -
- - -
+
+ + - {selectedNodeId && selectedImpact && !selectedNodeId.startsWith('dir:') && ( - setSelectedNodeId(null)} - onFileClick={handlePanelFileClick} - onFileHover={setHoveredFileId} - /> +
+ {viewMode === 'graph' && ( + + )} + {viewMode === 'matrix' && ( + )}
+ + {selectedFile && selectedImpact && ( + setSelectedFile(null)} + onFileClick={(fileId) => setSelectedFile(fileId)} + onFileHover={() => {}} + /> + )}
) } -export function DependencyGraph(props: DependencyGraphProps) { - return ( - - - - ) -} +// Default export for lazy loading +export default DependencyGraph diff --git a/frontend/src/components/DependencyGraph/types.ts b/frontend/src/components/DependencyGraph/types.ts new file mode 100644 index 0000000..038a410 --- /dev/null +++ b/frontend/src/components/DependencyGraph/types.ts @@ -0,0 +1,46 @@ +// Types for the dependency graph API response and internal data structures + +export interface ApiNode { + id: string + label?: string + type?: string + language?: string + import_count?: number + imports?: number +} + +export interface ApiEdge { + source: string + target: string + type?: string +} + +export interface GraphMetrics { + most_critical_files?: { file: string; dependents: number }[] + most_complex_files?: { file: string; dependencies: number }[] + avg_dependencies?: number + total_edges?: number +} + +export interface DependencyApiResponse { + nodes: ApiNode[] + edges: ApiEdge[] + dependencies?: Record + metrics?: GraphMetrics + total_files?: number + total_dependencies?: number + external_dependencies?: string[] + cached?: boolean +} + +// Matrix view data types +export interface MatrixData { + labels: string[] + shortLabels: string[] + matrix: number[][] + directories: Map + directorySeparators: number[] + cycles: [number, number][] + totalDeps: number + totalCycles: number +} From 3f6ac31303d109006531928d62049b7b945ada12 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Feb 2026 23:04:35 -0500 Subject: [PATCH 02/10] feat: implement Sigma.js GraphView and Dependency Matrix view GraphView (OPE-45): - WebGL rendering via Sigma.js with dark theme - ForceAtlas2 layout with Louvain community detection for cluster coloring - Hover highlights node neighborhood, fades rest - Click opens impact analysis, double-click zooms to node - Node tooltip showing file details on hover - Legend overlay explaining visual encoding MatrixView (OPE-46): - Dependency Structure Matrix showing file-to-file imports - Color intensity maps to coupling strength - Red cells highlight circular dependencies - Directory grouping with visual separators - Sticky row/column headers for scroll navigation - Row click triggers impact analysis - Stats bar showing file count, deps, and cycle count - Truncation for large matrices (150+ files) --- .../DependencyGraph/GraphView/NodeTooltip.tsx | 48 ++++ .../DependencyGraph/GraphView/index.tsx | 161 ++++++++++++- .../DependencyGraph/MatrixView/index.tsx | 224 +++++++++++++++++- 3 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx diff --git a/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx b/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx new file mode 100644 index 0000000..4ab27a9 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx @@ -0,0 +1,48 @@ +// Tooltip shown when hovering a node in the graph +// Positioned absolutely near the cursor + +interface NodeTooltipProps { + nodeId: string + label: string + directory: string + imports: number + dependents: number + riskLevel: string + position: { x: number; y: number } +} + +const RISK_COLORS: Record = { + low: 'text-emerald-400', + med: 'text-yellow-400', + high: 'text-rose-400', +} + +export function NodeTooltip({ + label, + directory, + imports, + dependents, + riskLevel, + position, +}: NodeTooltipProps) { + return ( +
+
{label}
+
{directory}
+
+ + {imports} imports + + + {dependents} dependents + +
+
+ ) +} diff --git a/frontend/src/components/DependencyGraph/GraphView/index.tsx b/frontend/src/components/DependencyGraph/GraphView/index.tsx index ce203b0..d6c5a23 100644 --- a/frontend/src/components/DependencyGraph/GraphView/index.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -1,17 +1,170 @@ -// Sigma.js WebGL graph rendering view -// TODO: OPE-45 -- full implementation with hover/click interactions +// Sigma.js WebGL graph view +// Renders the dependency graph using WebGL for performance +// Layout + clustering computed in useGraphData, rendering handled by Sigma +import { useState, useEffect } from 'react' +import { + SigmaContainer, + useSigma, + useRegisterEvents, + useLoadGraph, +} from '@react-sigma/core' +import '@react-sigma/core/lib/style.css' + +import { useGraphData } from './useGraphData' +import { NodeTooltip } from './NodeTooltip' import type { DependencyApiResponse } from '../types' +import type Graph from 'graphology' interface GraphViewProps { data: DependencyApiResponse onSelectFile?: (filePath: string) => void } +// Dark theme settings for sigma +const SIGMA_SETTINGS = { + defaultNodeColor: '#6366f1', + defaultEdgeColor: '#374151', + defaultEdgeType: 'arrow' as const, + renderEdgeLabels: false, + labelFont: 'Inter, system-ui, sans-serif', + labelSize: 12, + labelColor: { color: '#e5e7eb' }, + labelRenderedSizeThreshold: 8, + zIndex: true, + minCameraRatio: 0.05, + maxCameraRatio: 3, +} + +// Loads graph into Sigma and fits camera +function LoadAndDisplay({ graph }: { graph: Graph }) { + const loadGraph = useLoadGraph() + const sigma = useSigma() + + useEffect(() => { + loadGraph(graph) + // fit camera after graph loads + requestAnimationFrame(() => { + sigma.getCamera().animatedReset({ duration: 300 }) + }) + }, [graph, loadGraph, sigma]) + + return null +} + +// Handles all mouse/keyboard interactions on the graph +function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => void }) { + const sigma = useSigma() + const registerEvents = useRegisterEvents() + const [hoveredNode, setHoveredNode] = useState(null) + const [tooltip, setTooltip] = useState<{ + nodeId: string + position: { x: number; y: number } + } | null>(null) + + useEffect(() => { + registerEvents({ + enterNode: ({ node, event }) => { + setHoveredNode(node) + setTooltip({ nodeId: node, position: { x: event.x, y: event.y } }) + const el = sigma.getContainer() + if (el) el.style.cursor = 'pointer' + }, + leaveNode: () => { + setHoveredNode(null) + setTooltip(null) + const el = sigma.getContainer() + if (el) el.style.cursor = 'default' + }, + clickNode: ({ node }) => onSelectFile?.(node), + doubleClickNode: ({ node }) => { + const pos = sigma.getNodeDisplayData(node) + if (pos) { + sigma.getCamera().animate( + { x: pos.x, y: pos.y, ratio: 0.15 }, + { duration: 400 } + ) + } + }, + }) + }, [registerEvents, sigma, onSelectFile]) + + // highlight hovered node neighborhood, fade everything else + useEffect(() => { + const graph = sigma.getGraph() + + if (hoveredNode && graph.hasNode(hoveredNode)) { + const neighbors = new Set(graph.neighbors(hoveredNode)) + neighbors.add(hoveredNode) + + sigma.setSetting('nodeReducer', (node, data) => { + if (neighbors.has(node)) return { ...data, zIndex: 1 } + return { ...data, color: '#1f2937', label: '', zIndex: 0 } + }) + sigma.setSetting('edgeReducer', (edge, data) => { + const src = graph.source(edge) + const tgt = graph.target(edge) + if (neighbors.has(src) && neighbors.has(tgt)) { + return { ...data, color: '#6366f1', size: 1.5 } + } + return { ...data, hidden: true } + }) + } else { + sigma.setSetting('nodeReducer', null) + sigma.setSetting('edgeReducer', null) + } + }, [hoveredNode, sigma]) + + // build tooltip data from graph attributes + const tooltipData = (() => { + if (!tooltip) return null + const graph = sigma.getGraph() + if (!graph.hasNode(tooltip.nodeId)) return null + const a = graph.getNodeAttributes(tooltip.nodeId) + return { + nodeId: tooltip.nodeId, + label: (a.label as string) || tooltip.nodeId, + directory: (a.directory as string) || '', + imports: (a.imports as number) || 0, + dependents: (a.dependents as number) || 0, + riskLevel: (a.riskLevel as string) || 'low', + position: tooltip.position, + } + })() + + return tooltipData ? : null +} + export function GraphView({ data, onSelectFile }: GraphViewProps) { + const graph = useGraphData(data) + + if (!graph || graph.order === 0) { + return ( +
+ No graph data available +
+ ) + } + return ( -
- GraphView placeholder -- {data.nodes?.length || 0} nodes ready for Sigma.js rendering +
+ + + + + +
+
Legend
+
+
Node size = dependents count
+
Node color = module cluster
+
Hover to highlight neighbors
+
Click to analyze impact
+
+
) } diff --git a/frontend/src/components/DependencyGraph/MatrixView/index.tsx b/frontend/src/components/DependencyGraph/MatrixView/index.tsx index 1a7bc68..5deb26e 100644 --- a/frontend/src/components/DependencyGraph/MatrixView/index.tsx +++ b/frontend/src/components/DependencyGraph/MatrixView/index.tsx @@ -1,6 +1,9 @@ // Dependency Structure Matrix (DSM) view -// TODO: OPE-46 -- full implementation with cycle detection + directory grouping +// Renders a colored grid showing file-to-file import relationships +// Red cells = circular dependencies, blue intensity = coupling strength +import { useState, useRef, useMemo, useCallback } from 'react' +import { useMatrixData } from './useMatrixData' import type { DependencyApiResponse } from '../types' interface MatrixViewProps { @@ -8,10 +11,225 @@ interface MatrixViewProps { onSelectFile?: (filePath: string) => void } +// color intensity based on import count +function getCellColor(value: number, isCycle: boolean): string { + if (isCycle) return 'bg-rose-500/70' + if (value === 0) return '' + if (value === 1) return 'bg-indigo-500/20' + if (value <= 3) return 'bg-indigo-500/40' + return 'bg-indigo-500/60' +} + +function CellTooltip({ + source, + target, + count, + isCycle, + position, +}: { + source: string + target: string + count: number + isCycle: boolean + position: { x: number; y: number } +}) { + return ( +
+
{source.split('/').pop()}
+
+ imports {count}x from {target.split('/').pop()} +
+ {isCycle && ( +
Circular dependency
+ )} +
+ ) +} + export function MatrixView({ data, onSelectFile }: MatrixViewProps) { + const matrixData = useMatrixData(data) + const [hoveredCell, setHoveredCell] = useState<{ + row: number + col: number + mouseX: number + mouseY: number + } | null>(null) + const [highlightedIndex, setHighlightedIndex] = useState(null) + const containerRef = useRef(null) + + // build a set of cycle pairs for fast lookup + const cycleSet = useMemo(() => { + if (!matrixData) return new Set() + const set = new Set() + for (const [a, b] of matrixData.cycles) { + set.add(`${a}-${b}`) + set.add(`${b}-${a}`) + } + return set + }, [matrixData]) + + const isCycleCell = useCallback( + (row: number, col: number) => cycleSet.has(`${row}-${col}`), + [cycleSet] + ) + + if (!matrixData || matrixData.labels.length === 0) { + return ( +
+ No dependency data for matrix view +
+ ) + } + + const { labels, shortLabels, matrix, directorySeparators, totalDeps, totalCycles } = matrixData + const size = labels.length + + // limit rendering for very large matrices + const maxRender = 150 + const isTruncated = size > maxRender + const renderSize = Math.min(size, maxRender) + return ( -
- MatrixView placeholder -- {data.nodes?.length || 0} files ready for DSM rendering +
+ {/* stats */} +
+ {size} files + {totalDeps} dependencies + {totalCycles > 0 && ( + + {totalCycles} circular {totalCycles === 1 ? 'dependency' : 'dependencies'} + + )} + {isTruncated && ( + Showing first {maxRender} of {size} files + )} +
+ + {/* matrix grid */} +
+ + + + {/* empty corner cell */} + + ))} + + + + {matrix.slice(0, renderSize).map((row, rowIdx) => { + const isSeparator = directorySeparators.includes(rowIdx) + return ( + + {/* row header */} + + {/* cells */} + {row.slice(0, renderSize).map((value, colIdx) => { + const cycle = isCycleCell(rowIdx, colIdx) + const isHighlighted = + highlightedIndex === rowIdx || highlightedIndex === colIdx + const isDiagonal = rowIdx === colIdx + + return ( + + ) + })} + +
+ {/* column headers */} + {shortLabels.slice(0, renderSize).map((label, col) => ( + setHighlightedIndex(col)} + onMouseLeave={() => setHighlightedIndex(null)} + > +
+ {label} +
+
onSelectFile?.(labels[rowIdx])} + onMouseEnter={() => setHighlightedIndex(rowIdx)} + onMouseLeave={() => setHighlightedIndex(null)} + > + {shortLabels[rowIdx]} + 0 ? 'ring-1 ring-zinc-500' : ''}`} + onMouseEnter={(e) => + value > 0 + ? setHoveredCell({ + row: rowIdx, + col: colIdx, + mouseX: e.clientX, + mouseY: e.clientY, + }) + : undefined + } + onMouseLeave={() => setHoveredCell(null)} + /> + ) + })} +
+
+ + {/* color legend */} +
+
+
+ 1 import +
+
+
+ 2-3 imports +
+
+
+ 4+ imports +
+
+
+ Circular dep +
+
+
+ Self +
+
+ + {/* tooltip */} + {hoveredCell && ( + + )}
) } From ab334097e1c9c365393e20a1011736bab2887974 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Feb 2026 23:19:58 -0500 Subject: [PATCH 03/10] fix: dark theme for graph view -- dark bg, subtle edges, better label density - Set canvas background to zinc-950 (#09090b) matching our dark theme - Edges now nearly invisible (12% opacity) until hover reveals neighbors - Label density reduced so only important nodes show labels at default zoom - Hovered node gets white border glow, neighbors show labels - Non-neighbor nodes fade to 30% opacity on hover - Legend uses glass card style with backdrop blur - Added border around graph container for visual definition --- .../DependencyGraph/GraphView/index.tsx | 68 +++++++++++++------ .../DependencyGraph/GraphView/useGraphData.ts | 2 + 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/DependencyGraph/GraphView/index.tsx b/frontend/src/components/DependencyGraph/GraphView/index.tsx index d6c5a23..f56f5c9 100644 --- a/frontend/src/components/DependencyGraph/GraphView/index.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -21,19 +21,28 @@ interface GraphViewProps { onSelectFile?: (filePath: string) => void } -// Dark theme settings for sigma +// dark theme -- bg matches zinc-950, edges nearly invisible until hover const SIGMA_SETTINGS = { defaultNodeColor: '#6366f1', - defaultEdgeColor: '#374151', + defaultEdgeColor: 'rgba(75, 85, 99, 0.15)', defaultEdgeType: 'arrow' as const, + edgeReducer: null as any, + nodeReducer: null as any, renderEdgeLabels: false, labelFont: 'Inter, system-ui, sans-serif', - labelSize: 12, - labelColor: { color: '#e5e7eb' }, - labelRenderedSizeThreshold: 8, + labelSize: 11, + labelWeight: '500', + labelColor: { color: '#d1d5db' }, + // only show labels for large (important) nodes at default zoom + labelRenderedSizeThreshold: 14, + labelDensity: 0.15, zIndex: true, - minCameraRatio: 0.05, + minCameraRatio: 0.03, maxCameraRatio: 3, + stagePadding: 40, + // subtle node borders + defaultNodeBorderSize: 1, + defaultNodeBorderColor: 'rgba(255, 255, 255, 0.08)', } // Loads graph into Sigma and fits camera @@ -43,7 +52,6 @@ function LoadAndDisplay({ graph }: { graph: Graph }) { useEffect(() => { loadGraph(graph) - // fit camera after graph loads requestAnimationFrame(() => { sigma.getCamera().animatedReset({ duration: 300 }) }) @@ -52,7 +60,7 @@ function LoadAndDisplay({ graph }: { graph: Graph }) { return null } -// Handles all mouse/keyboard interactions on the graph +// Handles hover/click interactions and drives node/edge reducers function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => void }) { const sigma = useSigma() const registerEvents = useRegisterEvents() @@ -89,7 +97,7 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v }) }, [registerEvents, sigma, onSelectFile]) - // highlight hovered node neighborhood, fade everything else + // highlight hovered neighborhood, fade everything else useEffect(() => { const graph = sigma.getGraph() @@ -98,14 +106,32 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v neighbors.add(hoveredNode) sigma.setSetting('nodeReducer', (node, data) => { - if (neighbors.has(node)) return { ...data, zIndex: 1 } - return { ...data, color: '#1f2937', label: '', zIndex: 0 } + if (neighbors.has(node)) { + return { + ...data, + zIndex: 1, + // show label on hover for all neighbors + label: data.label, + // slight glow effect via larger border + borderSize: node === hoveredNode ? 3 : 1, + borderColor: node === hoveredNode ? '#ffffff' : 'rgba(255,255,255,0.2)', + } + } + // fade non-neighbors hard + return { + ...data, + color: 'rgba(31, 41, 55, 0.3)', + label: '', + zIndex: 0, + borderSize: 0, + } }) + sigma.setSetting('edgeReducer', (edge, data) => { const src = graph.source(edge) const tgt = graph.target(edge) if (neighbors.has(src) && neighbors.has(tgt)) { - return { ...data, color: '#6366f1', size: 1.5 } + return { ...data, color: 'rgba(99, 102, 241, 0.6)', size: 1.5 } } return { ...data, hidden: true } }) @@ -115,7 +141,6 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v } }, [hoveredNode, sigma]) - // build tooltip data from graph attributes const tooltipData = (() => { if (!tooltip) return null const graph = sigma.getGraph() @@ -147,22 +172,23 @@ export function GraphView({ data, onSelectFile }: GraphViewProps) { } return ( -
+
-
-
Legend
-
+ {/* legend - glass card style */} +
+
Legend
+
Node size = dependents count
-
Node color = module cluster
-
Hover to highlight neighbors
-
Click to analyze impact
+
Color = module cluster
+
Hover = highlight neighbors
+
Click = impact analysis
diff --git a/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts index 4e31c84..6443746 100644 --- a/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts +++ b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts @@ -71,6 +71,8 @@ export function useGraphData(apiData: DependencyApiResponse | undefined) { graph.addEdge(edge.source, edge.target, { weight: 1, type: 'arrow', + size: 0.5, + color: 'rgba(75, 85, 99, 0.12)', }) } } From fbcf7ad09fae690eb89a0686897b1efa4359b12c Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Feb 2026 23:26:11 -0500 Subject: [PATCH 04/10] feat: add search, controls, fix UX issues in dependency graph - Add SearchBar: type to find files, fuzzy match, zoom-to-focus on select Keyboard shortcut: / to open, Escape to clear - Add GraphControls: zoom in/out, fit-to-screen, center buttons - Fix ImpactPanel positioning: now overlays as right sidebar on graph - Filter orphan nodes (0 connections) to reduce visual noise - Tune ForceAtlas2: lower gravity, higher scaling for spread-out layout linLogMode + outboundAttractionDistribution for better clustering - Increase iterations to 400 for more settled layout - hideEdgesOnMove for better pan/zoom performance --- .../GraphView/GraphControls.tsx | 39 ++++ .../DependencyGraph/GraphView/SearchBar.tsx | 168 ++++++++++++++++++ .../DependencyGraph/GraphView/index.tsx | 50 +++--- .../DependencyGraph/GraphView/useGraphData.ts | 25 ++- .../src/components/DependencyGraph/index.tsx | 25 +-- 5 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx create mode 100644 frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx diff --git a/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx b/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx new file mode 100644 index 0000000..ced4ad0 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx @@ -0,0 +1,39 @@ +// Graph controls: zoom in, zoom out, fit-to-screen, center +// Positioned bottom-left of the graph canvas + +import { useCallback } from 'react' +import { useSigma, useCamera } from '@react-sigma/core' +import { ZoomIn, ZoomOut, Maximize2, LocateFixed } from 'lucide-react' + +const BTN = 'p-1.5 bg-zinc-900/80 backdrop-blur-sm border border-zinc-700 rounded-md text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors' + +export function GraphControls() { + const sigma = useSigma() + const { zoomIn, zoomOut, reset } = useCamera({ duration: 300, factor: 1.5 }) + + const fitToScreen = useCallback(() => { + reset() + }, [reset]) + + const centerGraph = useCallback(() => { + // zoom to fit all nodes with some padding + sigma.getCamera().animatedReset({ duration: 300 }) + }, [sigma]) + + return ( +
+ + + + +
+ ) +} diff --git a/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx new file mode 100644 index 0000000..4ee5c1b --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx @@ -0,0 +1,168 @@ +// Search bar for finding and focusing nodes in the graph +// Floats top-left of the graph canvas + +import { useState, useRef, useEffect, useCallback } from 'react' +import { Search, X } from 'lucide-react' +import { useSigma } from '@react-sigma/core' + +export function SearchBar() { + const sigma = useSigma() + const [query, setQuery] = useState('') + const [results, setResults] = useState<{ id: string; label: string }[]>([]) + const [isOpen, setIsOpen] = useState(false) + const inputRef = useRef(null) + + // fuzzy match against all node labels and full paths + useEffect(() => { + if (!query.trim()) { + setResults([]) + return + } + + const graph = sigma.getGraph() + const q = query.toLowerCase() + const matches: { id: string; label: string }[] = [] + + graph.forEachNode((node, attrs) => { + const label = (attrs.label as string) || '' + const fullPath = node.toLowerCase() + if (fullPath.includes(q) || label.toLowerCase().includes(q)) { + matches.push({ id: node, label }) + } + }) + + // sort: exact filename matches first, then by path + matches.sort((a, b) => { + const aExact = a.label.toLowerCase().startsWith(q) ? 0 : 1 + const bExact = b.label.toLowerCase().startsWith(q) ? 0 : 1 + return aExact - bExact || a.id.localeCompare(b.id) + }) + + setResults(matches.slice(0, 8)) + }, [query, sigma]) + + const focusNode = useCallback((nodeId: string) => { + const graph = sigma.getGraph() + if (!graph.hasNode(nodeId)) return + + // highlight this node and neighbors + const neighbors = new Set(graph.neighbors(nodeId)) + neighbors.add(nodeId) + + sigma.setSetting('nodeReducer', (node, data) => { + if (neighbors.has(node)) { + return { + ...data, + zIndex: 1, + label: data.label, + borderSize: node === nodeId ? 3 : 1, + borderColor: node === nodeId ? '#ffffff' : 'rgba(255,255,255,0.2)', + } + } + return { ...data, color: 'rgba(31, 41, 55, 0.3)', label: '', zIndex: 0, borderSize: 0 } + }) + sigma.setSetting('edgeReducer', (edge, data) => { + const src = graph.source(edge) + const tgt = graph.target(edge) + if (neighbors.has(src) && neighbors.has(tgt)) { + return { ...data, color: 'rgba(99, 102, 241, 0.6)', size: 1.5 } + } + return { ...data, hidden: true } + }) + + // zoom to the node + const pos = sigma.getNodeDisplayData(nodeId) + if (pos) { + sigma.getCamera().animate( + { x: pos.x, y: pos.y, ratio: 0.15 }, + { duration: 400 } + ) + } + + setQuery('') + setResults([]) + setIsOpen(false) + }, [sigma]) + + const clearSearch = useCallback(() => { + setQuery('') + setResults([]) + setIsOpen(false) + // reset reducers + sigma.setSetting('nodeReducer', null) + sigma.setSetting('edgeReducer', null) + sigma.getCamera().animatedReset({ duration: 300 }) + }, [sigma]) + + // keyboard shortcut: / or cmd+f to focus search + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === '/' && !isOpen) { + e.preventDefault() + setIsOpen(true) + setTimeout(() => inputRef.current?.focus(), 50) + } + if (e.key === 'Escape' && isOpen) { + clearSearch() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [isOpen, clearSearch]) + + return ( +
+ {!isOpen ? ( + + ) : ( +
+
+ + setQuery(e.target.value)} + placeholder="Search files..." + className="flex-1 bg-transparent text-sm text-zinc-200 outline-none placeholder:text-zinc-500" + /> + +
+ + {results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} + + {query && results.length === 0 && ( +
+ No files found +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/components/DependencyGraph/GraphView/index.tsx b/frontend/src/components/DependencyGraph/GraphView/index.tsx index f56f5c9..2f726f6 100644 --- a/frontend/src/components/DependencyGraph/GraphView/index.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -1,6 +1,5 @@ // Sigma.js WebGL graph view -// Renders the dependency graph using WebGL for performance -// Layout + clustering computed in useGraphData, rendering handled by Sigma +// Renders dependency graph with search, controls, hover/click interactions import { useState, useEffect } from 'react' import { @@ -13,6 +12,8 @@ import '@react-sigma/core/lib/style.css' import { useGraphData } from './useGraphData' import { NodeTooltip } from './NodeTooltip' +import { SearchBar } from './SearchBar' +import { GraphControls } from './GraphControls' import type { DependencyApiResponse } from '../types' import type Graph from 'graphology' @@ -21,10 +22,10 @@ interface GraphViewProps { onSelectFile?: (filePath: string) => void } -// dark theme -- bg matches zinc-950, edges nearly invisible until hover +// dark bg, subtle edges, only important labels visible const SIGMA_SETTINGS = { defaultNodeColor: '#6366f1', - defaultEdgeColor: 'rgba(75, 85, 99, 0.15)', + defaultEdgeColor: 'rgba(75, 85, 99, 0.12)', defaultEdgeType: 'arrow' as const, edgeReducer: null as any, nodeReducer: null as any, @@ -33,34 +34,35 @@ const SIGMA_SETTINGS = { labelSize: 11, labelWeight: '500', labelColor: { color: '#d1d5db' }, - // only show labels for large (important) nodes at default zoom - labelRenderedSizeThreshold: 14, - labelDensity: 0.15, + labelRenderedSizeThreshold: 12, + labelDensity: 0.12, zIndex: true, minCameraRatio: 0.03, maxCameraRatio: 3, - stagePadding: 40, - // subtle node borders + stagePadding: 30, defaultNodeBorderSize: 1, - defaultNodeBorderColor: 'rgba(255, 255, 255, 0.08)', + defaultNodeBorderColor: 'rgba(255, 255, 255, 0.06)', + // performance: skip edges when zoomed out + hideEdgesOnMove: true, } -// Loads graph into Sigma and fits camera function LoadAndDisplay({ graph }: { graph: Graph }) { const loadGraph = useLoadGraph() const sigma = useSigma() useEffect(() => { loadGraph(graph) + // let sigma calculate bounds, then fit the camera with padding requestAnimationFrame(() => { - sigma.getCamera().animatedReset({ duration: 300 }) + requestAnimationFrame(() => { + sigma.getCamera().animatedReset({ duration: 400 }) + }) }) }, [graph, loadGraph, sigma]) return null } -// Handles hover/click interactions and drives node/edge reducers function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => void }) { const sigma = useSigma() const registerEvents = useRegisterEvents() @@ -89,7 +91,7 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v const pos = sigma.getNodeDisplayData(node) if (pos) { sigma.getCamera().animate( - { x: pos.x, y: pos.y, ratio: 0.15 }, + { x: pos.x, y: pos.y, ratio: 0.12 }, { duration: 400 } ) } @@ -110,28 +112,18 @@ function Interactions({ onSelectFile }: { onSelectFile?: (filePath: string) => v return { ...data, zIndex: 1, - // show label on hover for all neighbors label: data.label, - // slight glow effect via larger border borderSize: node === hoveredNode ? 3 : 1, - borderColor: node === hoveredNode ? '#ffffff' : 'rgba(255,255,255,0.2)', + borderColor: node === hoveredNode ? '#ffffff' : 'rgba(255,255,255,0.15)', } } - // fade non-neighbors hard - return { - ...data, - color: 'rgba(31, 41, 55, 0.3)', - label: '', - zIndex: 0, - borderSize: 0, - } + return { ...data, color: 'rgba(31, 41, 55, 0.25)', label: '', zIndex: 0, borderSize: 0 } }) - sigma.setSetting('edgeReducer', (edge, data) => { const src = graph.source(edge) const tgt = graph.target(edge) if (neighbors.has(src) && neighbors.has(tgt)) { - return { ...data, color: 'rgba(99, 102, 241, 0.6)', size: 1.5 } + return { ...data, color: 'rgba(99, 102, 241, 0.5)', size: 1.5 } } return { ...data, hidden: true } }) @@ -179,9 +171,11 @@ export function GraphView({ data, onSelectFile }: GraphViewProps) { > + + - {/* legend - glass card style */} + {/* legend */}
Legend
diff --git a/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts index 6443746..c4fc588 100644 --- a/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts +++ b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts @@ -38,16 +38,22 @@ export function useGraphData(apiData: DependencyApiResponse | undefined) { const graph = new Graph({ type: 'directed' }) - // Build in-degree map to know dependents count per node + // Build in-degree and out-degree maps const inDegree: Record = {} + const outDegree: Record = {} for (const edge of apiData.edges) { inDegree[edge.target] = (inDegree[edge.target] || 0) + 1 + outDegree[edge.source] = (outDegree[edge.source] || 0) + 1 } - // Add nodes + // Add nodes -- skip orphans (0 connections) to reduce noise for (const node of apiData.nodes) { const dependents = inDegree[node.id] || 0 const imports = node.import_count ?? node.imports ?? 0 + const totalConnections = dependents + (outDegree[node.id] || 0) + + // filter out isolated nodes (no edges at all) -- they clutter the graph + if (totalConnections === 0) continue graph.addNode(node.id, { label: node.label || node.id.split('/').pop() || node.id, @@ -105,18 +111,19 @@ export function useGraphData(apiData: DependencyApiResponse | undefined) { }) } - // Run ForceAtlas2 for layout positions - // Use synchronous version with fixed iterations for deterministic layout + // ForceAtlas2 for layout -- tuned for readability over compactness forceAtlas2.assign(graph, { - iterations: 300, + iterations: 400, settings: { - gravity: 1, - scalingRatio: 10, - barnesHutOptimize: graph.order > 100, + gravity: 0.5, + scalingRatio: 20, + barnesHutOptimize: graph.order > 50, barnesHutTheta: 0.5, - slowDown: 5, + slowDown: 3, strongGravityMode: false, adjustSizes: true, + linLogMode: true, + outboundAttractionDistribution: true, }, }) diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx index 6096e0b..a49b90c 100644 --- a/frontend/src/components/DependencyGraph/index.tsx +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -127,18 +127,21 @@ export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps {viewMode === 'matrix' && ( )} -
- {selectedFile && selectedImpact && ( - setSelectedFile(null)} - onFileClick={(fileId) => setSelectedFile(fileId)} - onFileHover={() => {}} - /> - )} + {/* impact panel overlays as right sidebar */} + {selectedFile && selectedImpact && ( +
+ setSelectedFile(null)} + onFileClick={(fileId) => setSelectedFile(fileId)} + onFileHover={() => {}} + /> +
+ )} +
) } From 0da3dbac735f3a680445142a20cfc6021c6c8357 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 17 Feb 2026 23:29:52 -0500 Subject: [PATCH 05/10] fix: search breaking graph -- shared highlight state between search and hover Root cause: SearchBar and Interactions both set nodeReducer/edgeReducer independently. When search set reducers, hover's useEffect fired with hoveredNode=null and reset them to null, blanking the graph. Fix: lift highlightedNode state to GraphView parent. Both SearchBar and Interactions write to the same state. Single useEffect drives the reducers from that shared state. Also fixed: camera zoom using graph coords (node attributes x/y) instead of display coords (getNodeDisplayData) which were viewport- relative and caused zoom to wrong position. Also: clickStage clears highlight, keyboard handler skips when user is typing in other inputs. --- .../DependencyGraph/GraphView/SearchBar.tsx | 67 +++++++----------- .../DependencyGraph/GraphView/index.tsx | 70 ++++++++++++------- 2 files changed, 67 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx index 4ee5c1b..fe022ed 100644 --- a/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx @@ -1,11 +1,15 @@ // Search bar for finding and focusing nodes in the graph -// Floats top-left of the graph canvas +// Uses shared highlight state from parent -- doesn't touch reducers directly import { useState, useRef, useEffect, useCallback } from 'react' import { Search, X } from 'lucide-react' import { useSigma } from '@react-sigma/core' -export function SearchBar() { +interface SearchBarProps { + onFocusNode: (nodeId: string | null) => void +} + +export function SearchBar({ onFocusNode }: SearchBarProps) { const sigma = useSigma() const [query, setQuery] = useState('') const [results, setResults] = useState<{ id: string; label: string }[]>([]) @@ -31,7 +35,6 @@ export function SearchBar() { } }) - // sort: exact filename matches first, then by path matches.sort((a, b) => { const aExact = a.label.toLowerCase().startsWith(q) ? 0 : 1 const bExact = b.label.toLowerCase().startsWith(q) ? 0 : 1 @@ -45,58 +48,36 @@ export function SearchBar() { const graph = sigma.getGraph() if (!graph.hasNode(nodeId)) return - // highlight this node and neighbors - const neighbors = new Set(graph.neighbors(nodeId)) - neighbors.add(nodeId) - - sigma.setSetting('nodeReducer', (node, data) => { - if (neighbors.has(node)) { - return { - ...data, - zIndex: 1, - label: data.label, - borderSize: node === nodeId ? 3 : 1, - borderColor: node === nodeId ? '#ffffff' : 'rgba(255,255,255,0.2)', - } - } - return { ...data, color: 'rgba(31, 41, 55, 0.3)', label: '', zIndex: 0, borderSize: 0 } - }) - sigma.setSetting('edgeReducer', (edge, data) => { - const src = graph.source(edge) - const tgt = graph.target(edge) - if (neighbors.has(src) && neighbors.has(tgt)) { - return { ...data, color: 'rgba(99, 102, 241, 0.6)', size: 1.5 } - } - return { ...data, hidden: true } - }) + // tell parent to highlight this node (drives shared reducers) + onFocusNode(nodeId) - // zoom to the node - const pos = sigma.getNodeDisplayData(nodeId) - if (pos) { - sigma.getCamera().animate( - { x: pos.x, y: pos.y, ratio: 0.15 }, - { duration: 400 } - ) - } + // zoom to the node using graph coordinates (not display coords) + const attrs = graph.getNodeAttributes(nodeId) + sigma.getCamera().animate( + { x: attrs.x as number, y: attrs.y as number, ratio: 0.15 }, + { duration: 400 } + ) setQuery('') setResults([]) setIsOpen(false) - }, [sigma]) + }, [sigma, onFocusNode]) const clearSearch = useCallback(() => { setQuery('') setResults([]) setIsOpen(false) - // reset reducers - sigma.setSetting('nodeReducer', null) - sigma.setSetting('edgeReducer', null) + onFocusNode(null) sigma.getCamera().animatedReset({ duration: 300 }) - }, [sigma]) + }, [sigma, onFocusNode]) - // keyboard shortcut: / or cmd+f to focus search + // keyboard: / to open, Escape to clear useEffect(() => { const handler = (e: KeyboardEvent) => { + // don't capture if user is typing in another input + const target = e.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return + if (e.key === '/' && !isOpen) { e.preventDefault() setIsOpen(true) @@ -142,12 +123,12 @@ export function SearchBar() {
{results.length > 0 && ( -
+
{results.map((r) => ( )} +
+ {isDrilled ? ( + <> + {drillDir}/ + {fileMatrix!.files.length} files + + ) : ( + <> + {dirMatrix.directories.length} directories + {dirMatrix.totalDeps} dependencies + {dirMatrix.totalCycles > 0 && ( + + {dirMatrix.totalCycles} circular {dirMatrix.totalCycles === 1 ? 'dep' : 'deps'} + + )} + + )} +
- {/* matrix grid */} -
- - - - {/* empty corner cell */} - - ))} - - - - {matrix.slice(0, renderSize).map((row, rowIdx) => { - const isSeparator = directorySeparators.includes(rowIdx) - return ( - - {/* row header */} - - {/* cells */} - {row.slice(0, renderSize).map((value, colIdx) => { - const cycle = isCycleCell(rowIdx, colIdx) - const isHighlighted = - highlightedIndex === rowIdx || highlightedIndex === colIdx - const isDiagonal = rowIdx === colIdx - - return ( - - ) - })} - -
- {/* column headers */} - {shortLabels.slice(0, renderSize).map((label, col) => ( - setHighlightedIndex(col)} - onMouseLeave={() => setHighlightedIndex(null)} - > -
- {label} -
-
onSelectFile?.(labels[rowIdx])} - onMouseEnter={() => setHighlightedIndex(rowIdx)} - onMouseLeave={() => setHighlightedIndex(null)} - > - {shortLabels[rowIdx]} - 0 ? 'ring-1 ring-zinc-500' : ''}`} - onMouseEnter={(e) => - value > 0 - ? setHoveredCell({ - row: rowIdx, - col: colIdx, - mouseX: e.clientX, - mouseY: e.clientY, - }) - : undefined - } - onMouseLeave={() => setHoveredCell(null)} - /> - ) - })} -
-
+ {!isDrilled && ( +
+ Click a directory to see file-level dependencies within it +
+ )} - {/* color legend */} + {/* matrix */} + + + {/* legend */}
- 1 import + 1-2
-
- 2-3 imports +
+ 3-5
-
- 4+ imports +
+ 6-10
-
- Circular dep +
+ 10+
-
- Self +
+ Circular
+ {!isDrilled && ( + Numbers = import count between directories + )}
{/* tooltip */} {hoveredCell && ( )}
diff --git a/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts b/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts index 253ac74..6c6f44f 100644 --- a/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts +++ b/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts @@ -1,55 +1,74 @@ -// Transforms API dependency response into a 2D adjacency matrix -// for the Dependency Structure Matrix (DSM) view +// Transforms API response into directory-level matrix (default) +// and file-level matrix (drill-down on directory click) import { useMemo } from 'react' -import type { DependencyApiResponse, MatrixData } from '../types' +import type { DependencyApiResponse } from '../types' function getDirectory(filePath: string): string { const parts = filePath.split('/') return parts.length > 1 ? parts.slice(0, -1).join('/') : '.' } -function getShortLabel(filePath: string): string { - return filePath.split('/').pop() || filePath +function getShortDir(dirPath: string): string { + const parts = dirPath.split('/') + return parts[parts.length - 1] || dirPath } -export function useMatrixData(apiData: DependencyApiResponse | undefined): MatrixData | null { +export interface DirectoryMatrixData { + directories: string[] + shortLabels: string[] + matrix: number[][] + fileCounts: number[] + cycles: [number, number][] + totalDeps: number + totalCycles: number +} + +export interface FileMatrixData { + directory: string + files: string[] + shortLabels: string[] + matrix: number[][] + cycles: [number, number][] +} + +export function useDirectoryMatrix(apiData: DependencyApiResponse | undefined): DirectoryMatrixData | null { return useMemo(() => { if (!apiData?.nodes?.length) return null - // Sort files by directory so same-directory files are adjacent - const sortedFiles = [...apiData.nodes] - .map((n) => n.id) - .sort((a, b) => { - const dirA = getDirectory(a) - const dirB = getDirectory(b) - if (dirA !== dirB) return dirA.localeCompare(dirB) - return a.localeCompare(b) - }) - - // Build index lookup: file path -> matrix index - const indexMap = new Map() - sortedFiles.forEach((file, idx) => { - indexMap.set(file, idx) - }) - - const size = sortedFiles.length - - // Build adjacency matrix - // matrix[row][col] = number of imports from row -> col - const matrix: number[][] = Array.from({ length: size }, () => - new Array(size).fill(0) - ) + // collect all unique directories + const dirSet = new Set() + for (const node of apiData.nodes) { + dirSet.add(getDirectory(node.id)) + } + const directories = [...dirSet].sort() + + // map dir -> index + const dirIndex = new Map() + directories.forEach((dir, idx) => dirIndex.set(dir, idx)) + + const size = directories.length + const matrix: number[][] = Array.from({ length: size }, () => new Array(size).fill(0)) + + // count how many files per directory + const fileCounts = new Array(size).fill(0) + for (const node of apiData.nodes) { + const idx = dirIndex.get(getDirectory(node.id)) + if (idx !== undefined) fileCounts[idx]++ + } + // aggregate edges into directory-level for (const edge of apiData.edges) { - const sourceIdx = indexMap.get(edge.source) - const targetIdx = indexMap.get(edge.target) - if (sourceIdx !== undefined && targetIdx !== undefined) { - matrix[sourceIdx][targetIdx] += 1 + const srcDir = getDirectory(edge.source) + const tgtDir = getDirectory(edge.target) + const srcIdx = dirIndex.get(srcDir) + const tgtIdx = dirIndex.get(tgtDir) + if (srcIdx !== undefined && tgtIdx !== undefined) { + matrix[srcIdx][tgtIdx]++ } } - // Detect circular dependencies: both directions have imports + // detect circular deps between directories const cycles: [number, number][] = [] for (let i = 0; i < size; i++) { for (let j = i + 1; j < size; j++) { @@ -59,36 +78,62 @@ export function useMatrixData(apiData: DependencyApiResponse | undefined): Matri } } - // Build directory grouping and find separator positions - const directories = new Map() - const directorySeparators: number[] = [] - let prevDir = '' + return { + directories, + shortLabels: directories.map(getShortDir), + matrix, + fileCounts, + cycles, + totalDeps: apiData.edges.length, + totalCycles: cycles.length, + } + }, [apiData]) +} - sortedFiles.forEach((file, idx) => { - const dir = getDirectory(file) - if (!directories.has(dir)) { - directories.set(dir, []) - } - directories.get(dir)!.push(idx) +export function useFileMatrix( + apiData: DependencyApiResponse | undefined, + directory: string | null +): FileMatrixData | null { + return useMemo(() => { + if (!apiData?.nodes?.length || !directory) return null + + // get files in this directory + const files = apiData.nodes + .map((n) => n.id) + .filter((id) => getDirectory(id) === directory) + .sort() + + if (files.length === 0) return null + + const fileIndex = new Map() + files.forEach((f, idx) => fileIndex.set(f, idx)) - if (dir !== prevDir && idx > 0) { - directorySeparators.push(idx) + const size = files.length + const matrix: number[][] = Array.from({ length: size }, () => new Array(size).fill(0)) + + for (const edge of apiData.edges) { + const srcIdx = fileIndex.get(edge.source) + const tgtIdx = fileIndex.get(edge.target) + if (srcIdx !== undefined && tgtIdx !== undefined) { + matrix[srcIdx][tgtIdx]++ } - prevDir = dir - }) + } - const totalDeps = apiData.edges.length - const totalCycles = cycles.length + const cycles: [number, number][] = [] + for (let i = 0; i < size; i++) { + for (let j = i + 1; j < size; j++) { + if (matrix[i][j] > 0 && matrix[j][i] > 0) { + cycles.push([i, j]) + } + } + } return { - labels: sortedFiles, - shortLabels: sortedFiles.map(getShortLabel), + directory, + files, + shortLabels: files.map((f) => f.split('/').pop() || f), matrix, - directories, - directorySeparators, cycles, - totalDeps, - totalCycles, } - }, [apiData]) + }, [apiData, directory]) } From 7e4b223e73cc44a2c8d1c26220b00a6d412a1b4a Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Wed, 18 Feb 2026 10:26:34 -0500 Subject: [PATCH 08/10] fix: search zoom using graphToViewport + camera offset calculation The camera zoom was blanking the graph because we were passing wrong coordinates. Sigma's camera.animate expects coordinates in its own normalized space, not raw graph coords or viewport pixels. New approach: use sigma.graphToViewport() to find where the target node is currently on screen, calculate the pixel offset from center, then adjust the camera state proportionally. This works regardless of the current zoom level or camera position. Same fix applied to double-click zoom. --- .../DependencyGraph/GraphView/SearchBar.tsx | 39 +++++++++++++------ .../DependencyGraph/GraphView/index.tsx | 26 +++++++++---- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx index 452267f..3d27950 100644 --- a/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx @@ -49,18 +49,35 @@ export function SearchBar({ onFocusNode }: SearchBarProps) { // pin this node (parent handles highlight via reducers) onFocusNode(nodeId) - // zoom: get the node's position in sigma's coordinate system - // nodeDisplayData gives us the rendered position which we can use directly + // zoom to node: use framedGraphToViewport to get correct camera position + // sigma's camera x/y are in the graph's normalized coordinate space setTimeout(() => { - const displayData = sigma.getNodeDisplayData(nodeId) - if (displayData) { - // convert viewport pixel coords to graph coords for the camera - const graphCoords = sigma.viewportToGraph({ x: displayData.x, y: displayData.y }) - sigma.getCamera().animate( - { x: graphCoords.x, y: graphCoords.y, ratio: 0.2 }, - { duration: 500 } - ) - } + const nodeAttrs = graph.getNodeAttributes(nodeId) + const x = nodeAttrs.x as number + const y = nodeAttrs.y as number + + // get the graph bounding box to normalize coords + const camera = sigma.getCamera() + const { width, height } = sigma.getDimensions() + + // convert graph position to a camera state that centers on the node + // sigma internally normalizes graph coords, so we use graphToViewport + // then figure out what camera state would put that at center + const currentState = camera.getState() + const viewCenter = sigma.graphToViewport({ x, y }) + + // how far off-center is the node currently? + const dx = viewCenter.x - width / 2 + const dy = viewCenter.y - height / 2 + + // compute new camera position by shifting proportionally + const newX = currentState.x + (dx / width) * currentState.ratio + const newY = currentState.y + (dy / height) * currentState.ratio + + camera.animate( + { x: newX, y: newY, ratio: 0.2 }, + { duration: 500 } + ) }, 100) setQuery('') diff --git a/frontend/src/components/DependencyGraph/GraphView/index.tsx b/frontend/src/components/DependencyGraph/GraphView/index.tsx index f39de74..442715d 100644 --- a/frontend/src/components/DependencyGraph/GraphView/index.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -107,14 +107,24 @@ function Interactions({ onSelectFile?.(node) }, doubleClickNode: ({ node }) => { - const displayData = sigma.getNodeDisplayData(node) - if (displayData) { - const graphCoords = sigma.viewportToGraph({ x: displayData.x, y: displayData.y }) - sigma.getCamera().animate( - { x: graphCoords.x, y: graphCoords.y, ratio: 0.12 }, - { duration: 400 } - ) - } + // zoom to node using graphToViewport + camera offset calculation + const graph = sigma.getGraph() + if (!graph.hasNode(node)) return + const attrs = graph.getNodeAttributes(node) + const camera = sigma.getCamera() + const { width, height } = sigma.getDimensions() + const currentState = camera.getState() + const viewPos = sigma.graphToViewport({ x: attrs.x as number, y: attrs.y as number }) + const dx = viewPos.x - width / 2 + const dy = viewPos.y - height / 2 + camera.animate( + { + x: currentState.x + (dx / width) * currentState.ratio, + y: currentState.y + (dy / height) * currentState.ratio, + ratio: 0.12, + }, + { duration: 400 } + ) }, clickStage: () => { // clear pinned state when clicking empty space From 4a2c00e53659c4fdad44773dc21d2b5fd1df340d Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Wed, 18 Feb 2026 10:41:54 -0500 Subject: [PATCH 09/10] chore: cleanup old ReactFlow files, delete package-lock.json Removed: - DependencyGraph/GraphNode.tsx (old ReactFlow node component) - DependencyGraph/DirectoryNode.tsx (old ReactFlow directory node) - DependencyGraph/GraphToolbar.tsx (old ReactFlow toolbar) - frontend/package-lock.json (Bun only policy, OPE-9) No remaining references to reactflow anywhere in the codebase. CSS bundle dropped 4.2kb from removing reactflow styles. --- .../DependencyGraph/DirectoryNode.tsx | 99 ------------ .../components/DependencyGraph/GraphNode.tsx | 144 ------------------ .../DependencyGraph/GraphToolbar.tsx | 96 ------------ 3 files changed, 339 deletions(-) delete mode 100644 frontend/src/components/DependencyGraph/DirectoryNode.tsx delete mode 100644 frontend/src/components/DependencyGraph/GraphNode.tsx delete mode 100644 frontend/src/components/DependencyGraph/GraphToolbar.tsx diff --git a/frontend/src/components/DependencyGraph/DirectoryNode.tsx b/frontend/src/components/DependencyGraph/DirectoryNode.tsx deleted file mode 100644 index c05b0a0..0000000 --- a/frontend/src/components/DependencyGraph/DirectoryNode.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { memo } from 'react' -import { Handle, Position } from 'reactflow' -import type { NodeProps } from 'reactflow' -import { Folder, FolderOpen, ChevronRight } from 'lucide-react' -import { cn } from '@/lib/utils' -import { Badge } from '@/components/ui/badge' -import type { RiskLevel } from './hooks/useImpactAnalysis' - -export interface DirectoryNodeData { - label: string - fullPath: string - fileCount: number - totalDependents: number - maxRisk: RiskLevel - isExpanded: boolean - state: 'default' | 'selected' | 'direct' | 'transitive' | 'dimmed' -} - -const STATE_STYLES: Record = { - default: 'border-zinc-300 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/90', - selected: 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20', - direct: 'border-rose-500 bg-rose-50 dark:bg-rose-950 ring-1 ring-rose-500/30', - transitive: 'border-amber-500 bg-amber-50 dark:bg-amber-950 ring-1 ring-amber-500/30', - dimmed: 'border-zinc-300 bg-zinc-100 opacity-50 dark:border-zinc-600 dark:bg-zinc-800/80', -} - -const RISK_STYLES: Record = { - low: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400', - medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400', - high: 'bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-400', - critical: 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400', -} - -function DirectoryNodeComponent({ data }: NodeProps) { - const stateStyle = STATE_STYLES[data.state] - const FolderIcon = data.isExpanded ? FolderOpen : Folder - - return ( - <> - - -
-
- - - {data.label}/ - - -
- -
- - {data.fileCount} file{data.fileCount !== 1 ? 's' : ''} - - - = 30 ? 'text-rose-600 dark:text-rose-400' : - data.totalDependents >= 10 ? 'text-amber-600 dark:text-amber-400' : - 'text-zinc-500 dark:text-zinc-400' - )}> - {data.totalDependents} deps - - {data.maxRisk !== 'low' && ( - - {data.maxRisk === 'critical' ? 'Crit' : data.maxRisk === 'high' ? 'High' : 'Med'} - - )} -
-
- - - - ) -} - -export const DirectoryNode = memo(DirectoryNodeComponent) diff --git a/frontend/src/components/DependencyGraph/GraphNode.tsx b/frontend/src/components/DependencyGraph/GraphNode.tsx deleted file mode 100644 index 003a4ab..0000000 --- a/frontend/src/components/DependencyGraph/GraphNode.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { memo } from 'react' -import { Handle, Position } from 'reactflow' -import type { NodeProps } from 'reactflow' -import { - FileCode2, - FileJson, - FileText, - TestTube2, - Settings, - File -} from 'lucide-react' -import { cn } from '@/lib/utils' -import { Badge } from '@/components/ui/badge' -import type { RiskLevel } from './hooks/useImpactAnalysis' - -export interface GraphNodeData { - label: string - fullPath: string - language: string - dependentCount: number - importCount: number - loc?: number - riskLevel: RiskLevel - isEntryPoint: boolean - state: 'default' | 'selected' | 'direct' | 'transitive' | 'dimmed' -} - -const FILE_ICONS: Record = { - python: FileCode2, - javascript: FileCode2, - typescript: FileCode2, - json: FileJson, - yaml: FileText, - markdown: FileText, - test: TestTube2, - config: Settings, - unknown: File, -} - -const LANGUAGE_COLORS: Record = { - python: 'text-blue-500 dark:text-blue-400', - javascript: 'text-yellow-600 dark:text-yellow-400', - typescript: 'text-blue-600 dark:text-blue-500', - json: 'text-zinc-500 dark:text-zinc-400', - yaml: 'text-zinc-500 dark:text-zinc-400', - markdown: 'text-zinc-500 dark:text-zinc-400', - config: 'text-zinc-500 dark:text-zinc-400', - test: 'text-purple-500 dark:text-purple-400', - unknown: 'text-zinc-400 dark:text-zinc-500', -} - -const STATE_STYLES: Record = { - default: 'border-zinc-300 bg-white dark:border-zinc-700 dark:bg-zinc-900/90', - selected: 'border-indigo-500 bg-white dark:bg-zinc-900 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20', - direct: 'border-rose-500 bg-white dark:bg-zinc-900 ring-1 ring-rose-500/30', - transitive: 'border-amber-500 bg-white dark:bg-zinc-900 ring-1 ring-amber-500/30', - dimmed: 'border-zinc-300 bg-zinc-100 opacity-50 dark:border-zinc-600 dark:bg-zinc-800/80', -} - -const RISK_CONFIG: Record = { - low: { variant: 'secondary', label: 'Low', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' }, - medium: { variant: 'secondary', label: 'Med', className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400' }, - high: { variant: 'secondary', label: 'High', className: 'bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-400' }, - critical: { variant: 'destructive', label: 'Crit', className: 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400' }, -} - -function getFileType(path: string, language: string): string { - const fileName = path.split('/').pop() || '' - const lower = fileName.toLowerCase() - const ext = lower.split('.').pop() || '' - - if (lower.includes('.test.') || lower.includes('_test.') || lower.includes('.spec.')) { - return 'test' - } - if (ext === 'json') return 'json' - if (ext === 'yaml' || ext === 'yml') return 'yaml' - if (ext === 'md' || ext === 'markdown') return 'markdown' - if (lower.includes('config')) return 'config' - if (['python', 'javascript', 'typescript'].includes(language)) return language - return 'unknown' -} - -function GraphNodeComponent({ data }: NodeProps) { - const fileType = getFileType(data.fullPath, data.language) - const Icon = FILE_ICONS[fileType] || FILE_ICONS.unknown - const iconColor = LANGUAGE_COLORS[fileType] || LANGUAGE_COLORS.unknown - const stateStyle = STATE_STYLES[data.state] - const risk = RISK_CONFIG[data.riskLevel] - - return ( - <> - - -
-
- - - {data.label} - - {data.dependentCount > 0 && ( - - {risk.label} - - )} -
- -
- = 15 ? 'text-rose-600 dark:text-rose-400' : - data.dependentCount >= 5 ? 'text-amber-600 dark:text-amber-400' : - 'text-zinc-500 dark:text-zinc-400' - )}> - {data.dependentCount} dependents - - - {data.importCount} imports -
-
- - - - ) -} - -export const GraphNode = memo(GraphNodeComponent) diff --git a/frontend/src/components/DependencyGraph/GraphToolbar.tsx b/frontend/src/components/DependencyGraph/GraphToolbar.tsx deleted file mode 100644 index 7ac7050..0000000 --- a/frontend/src/components/DependencyGraph/GraphToolbar.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { memo } from 'react' -import { RotateCcw, Maximize2, Filter, Eye, EyeOff, FolderTree } from 'lucide-react' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' - -interface GraphToolbarProps { - totalFiles: number - visibleFiles: number - showAll: boolean - showTests: boolean - clusterByDir: boolean - onToggleShowAll: () => void - onToggleTests: () => void - onToggleCluster: () => void - onResetView: () => void - onFullscreen?: () => void -} - -function GraphToolbarComponent({ - totalFiles, - visibleFiles, - showAll, - showTests, - clusterByDir, - onToggleShowAll, - onToggleTests, - onToggleCluster, - onResetView, - onFullscreen, -}: GraphToolbarProps) { - return ( -
-
- - Showing {visibleFiles} - {!showAll && totalFiles > visibleFiles && ( - of {totalFiles} - )} - {' '}files - -
- -
- - - - - -
- -
- - - {onFullscreen && ( - - )} -
-
- ) -} - -export const GraphToolbar = memo(GraphToolbarComponent) From beda58c17586a14a6219195d2d6b5eafc9a8333b Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Wed, 18 Feb 2026 11:46:41 -0500 Subject: [PATCH 10/10] fix: address code review findings -- 6 bugs + 3 improvements Bugs fixed: 1. clickStage: removed 'undefined as any' cast, widened onSelectFile type to accept string | null for proper type safety 2. Tooltip position: convert container-relative event coords to viewport-relative using getBoundingClientRect() for fixed tooltip 3. NodeTooltip: removed unused nodeId from props interface 4. DependencyGraph: removed unused apiUrl from props, updated caller in DashboardHome.tsx to stop passing it 5. types.ts: removed dead MatrixData interface (replaced by DirectoryMatrixData and FileMatrixData) 6. totalDeps: now counts only cross-directory deps (off-diagonal matrix sum) instead of all edges including intra-directory Improvements: 7. SIGMA_SETTINGS: removed null as any casts for nodeReducer and edgeReducer -- these are set dynamically via setSetting() 8. GraphControls: consolidated fitToScreen and centerGraph into single fit-to-screen button (both were calling animatedReset) 9. MatrixView: cycleSet now computed once in parent MatrixView and passed as prop to MatrixGrid, eliminating duplicate computation Also: onMouseEnter on empty cells now explicitly calls onCellHover(null) instead of returning undefined --- .../GraphView/GraphControls.tsx | 20 +++------------ .../DependencyGraph/GraphView/NodeTooltip.tsx | 3 +-- .../DependencyGraph/GraphView/index.tsx | 17 +++++++------ .../DependencyGraph/MatrixView/index.tsx | 25 ++++++++----------- .../MatrixView/useMatrixData.ts | 10 +++++++- .../src/components/DependencyGraph/index.tsx | 7 +++--- .../src/components/DependencyGraph/types.ts | 12 --------- .../components/dashboard/DashboardHome.tsx | 1 - 8 files changed, 35 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx b/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx index ced4ad0..3d20ed1 100644 --- a/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx @@ -1,25 +1,14 @@ -// Graph controls: zoom in, zoom out, fit-to-screen, center +// Graph controls: zoom in, zoom out, fit to screen // Positioned bottom-left of the graph canvas -import { useCallback } from 'react' import { useSigma, useCamera } from '@react-sigma/core' -import { ZoomIn, ZoomOut, Maximize2, LocateFixed } from 'lucide-react' +import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react' const BTN = 'p-1.5 bg-zinc-900/80 backdrop-blur-sm border border-zinc-700 rounded-md text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors' export function GraphControls() { - const sigma = useSigma() const { zoomIn, zoomOut, reset } = useCamera({ duration: 300, factor: 1.5 }) - const fitToScreen = useCallback(() => { - reset() - }, [reset]) - - const centerGraph = useCallback(() => { - // zoom to fit all nodes with some padding - sigma.getCamera().animatedReset({ duration: 300 }) - }, [sigma]) - return (
- -
) } diff --git a/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx b/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx index 4ab27a9..df0c29a 100644 --- a/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx @@ -1,8 +1,7 @@ // Tooltip shown when hovering a node in the graph -// Positioned absolutely near the cursor +// Positioned with fixed coordinates (viewport-relative from parent) interface NodeTooltipProps { - nodeId: string label: string directory: string imports: number diff --git a/frontend/src/components/DependencyGraph/GraphView/index.tsx b/frontend/src/components/DependencyGraph/GraphView/index.tsx index 442715d..bf8485c 100644 --- a/frontend/src/components/DependencyGraph/GraphView/index.tsx +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -20,15 +20,13 @@ import type Graph from 'graphology' interface GraphViewProps { data: DependencyApiResponse - onSelectFile?: (filePath: string) => void + onSelectFile?: (filePath: string | null) => void } const SIGMA_SETTINGS = { defaultNodeColor: '#6366f1', defaultEdgeColor: 'rgba(75, 85, 99, 0.12)', defaultEdgeType: 'arrow' as const, - edgeReducer: null as any, - nodeReducer: null as any, renderEdgeLabels: false, labelFont: 'Inter, system-ui, sans-serif', labelSize: 11, @@ -75,7 +73,7 @@ function Interactions({ pinnedNode, setPinnedNode, }: { - onSelectFile?: (filePath: string) => void + onSelectFile?: (filePath: string | null) => void pinnedNode: string | null setPinnedNode: (node: string | null) => void }) { @@ -91,7 +89,12 @@ function Interactions({ registerEvents({ enterNode: ({ node, event }) => { setHoveredNode(node) - setTooltip({ nodeId: node, position: { x: event.x, y: event.y } }) + // convert container-relative coords to viewport-relative for fixed tooltip + const container = sigma.getContainer() + const rect = container?.getBoundingClientRect() + const x = (rect?.left ?? 0) + event.x + const y = (rect?.top ?? 0) + event.y + setTooltip({ nodeId: node, position: { x, y } }) const el = sigma.getContainer() if (el) el.style.cursor = 'pointer' }, @@ -127,9 +130,8 @@ function Interactions({ ) }, clickStage: () => { - // clear pinned state when clicking empty space setPinnedNode(null) - onSelectFile?.(undefined as any) + onSelectFile?.(null) }, }) }, [registerEvents, sigma, onSelectFile, setPinnedNode]) @@ -181,7 +183,6 @@ function Interactions({ if (!graph.hasNode(tooltip.nodeId)) return null const a = graph.getNodeAttributes(tooltip.nodeId) return { - nodeId: tooltip.nodeId, label: (a.label as string) || tooltip.nodeId, directory: (a.directory as string) || '', imports: (a.imports as number) || 0, diff --git a/frontend/src/components/DependencyGraph/MatrixView/index.tsx b/frontend/src/components/DependencyGraph/MatrixView/index.tsx index 38a3875..7c06d65 100644 --- a/frontend/src/components/DependencyGraph/MatrixView/index.tsx +++ b/frontend/src/components/DependencyGraph/MatrixView/index.tsx @@ -29,23 +29,16 @@ function MatrixGrid({ onRowClick, highlightedIndex, setHighlightedIndex, + cycleSet, }: { labels: string[] matrix: number[][] - cycles: [number, number][] + cycleSet: Set onCellHover: (info: { row: number; col: number; x: number; y: number } | null) => void onRowClick: (index: number) => void highlightedIndex: number | null setHighlightedIndex: (i: number | null) => void }) { - const cycleSet = useMemo(() => { - const set = new Set() - for (const [a, b] of cycles) { - set.add(`${a}-${b}`) - set.add(`${b}-${a}`) - } - return set - }, [cycles]) const size = labels.length // cell size scales with matrix size for readability @@ -106,11 +99,13 @@ function MatrixGrid({ getCellBg(value, isCycle, isDiagonal) } ${isHighlighted && !isDiagonal ? 'ring-1 ring-inset ring-zinc-600' : ''}`} style={{ width: cellSize, height: cellSize, minWidth: cellSize }} - onMouseEnter={(e) => - value > 0 || isCycle - ? onCellHover({ row: rowIdx, col: colIdx, x: e.clientX, y: e.clientY }) - : undefined - } + onMouseEnter={(e) => { + if (value > 0 || isCycle) { + onCellHover({ row: rowIdx, col: colIdx, x: e.clientX, y: e.clientY }) + } else { + onCellHover(null) + } + }} onMouseLeave={() => onCellHover(null)} > {value > 0 && !isDiagonal && ( @@ -247,7 +242,7 @@ export function MatrixView({ data, onSelectFile }: MatrixViewProps) { ('graph') const [selectedFile, setSelectedFile] = useState(null) @@ -122,10 +121,10 @@ export function DependencyGraph({ repoId, apiUrl, apiKey }: DependencyGraphProps
{viewMode === 'graph' && ( - + setSelectedFile(f)} /> )} {viewMode === 'matrix' && ( - + setSelectedFile(f)} /> )} {/* impact panel overlays as right sidebar */} diff --git a/frontend/src/components/DependencyGraph/types.ts b/frontend/src/components/DependencyGraph/types.ts index 038a410..246c8cf 100644 --- a/frontend/src/components/DependencyGraph/types.ts +++ b/frontend/src/components/DependencyGraph/types.ts @@ -32,15 +32,3 @@ export interface DependencyApiResponse { external_dependencies?: string[] cached?: boolean } - -// Matrix view data types -export interface MatrixData { - labels: string[] - shortLabels: string[] - matrix: number[][] - directories: Map - directorySeparators: number[] - cycles: [number, number][] - totalDeps: number - totalCycles: number -} diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 8385a78..b4c0ade 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -457,7 +457,6 @@ export function DashboardHome() { {activeTab === 'dependencies' && ( )}