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/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) diff --git a/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx b/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx new file mode 100644 index 0000000..3d20ed1 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/GraphControls.tsx @@ -0,0 +1,25 @@ +// Graph controls: zoom in, zoom out, fit to screen +// Positioned bottom-left of the graph canvas + +import { useSigma, useCamera } from '@react-sigma/core' +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 { zoomIn, zoomOut, reset } = useCamera({ duration: 300, factor: 1.5 }) + + return ( +
+ + + +
+ ) +} diff --git a/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx b/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx new file mode 100644 index 0000000..df0c29a --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/NodeTooltip.tsx @@ -0,0 +1,47 @@ +// Tooltip shown when hovering a node in the graph +// Positioned with fixed coordinates (viewport-relative from parent) + +interface NodeTooltipProps { + 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/SearchBar.tsx b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx new file mode 100644 index 0000000..3d27950 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/SearchBar.tsx @@ -0,0 +1,169 @@ +// Search bar for finding and focusing nodes in the graph +// Sets pinnedNode in parent -- doesn't manage reducers + +import { useState, useRef, useEffect, useCallback } from 'react' +import { Search, X } from 'lucide-react' +import { useSigma } from '@react-sigma/core' + +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 }[]>([]) + const [isOpen, setIsOpen] = useState(false) + const inputRef = useRef(null) + + 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) || '' + if (node.toLowerCase().includes(q) || label.toLowerCase().includes(q)) { + matches.push({ id: node, label }) + } + }) + + 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 + + // pin this node (parent handles highlight via reducers) + onFocusNode(nodeId) + + // 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 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('') + setResults([]) + setIsOpen(false) + }, [sigma, onFocusNode]) + + const clearSearch = useCallback(() => { + setQuery('') + setResults([]) + setIsOpen(false) + onFocusNode(null) + sigma.getCamera().animatedReset({ duration: 300 }) + }, [sigma, onFocusNode]) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return + + 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 new file mode 100644 index 0000000..bf8485c --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/index.tsx @@ -0,0 +1,242 @@ +// Sigma.js WebGL graph view +// Highlight state: "pinned" (from click/search) vs "hovered" (from mouseover) +// Pinned persists until user clicks stage or closes panel. Hover is transient. + +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 { SearchBar } from './SearchBar' +import { GraphControls } from './GraphControls' +import type { DependencyApiResponse } from '../types' +import type Graph from 'graphology' + +interface GraphViewProps { + data: DependencyApiResponse + onSelectFile?: (filePath: string | null) => void +} + +const SIGMA_SETTINGS = { + defaultNodeColor: '#6366f1', + defaultEdgeColor: 'rgba(75, 85, 99, 0.12)', + defaultEdgeType: 'arrow' as const, + renderEdgeLabels: false, + labelFont: 'Inter, system-ui, sans-serif', + labelSize: 11, + labelWeight: '500', + labelColor: { color: '#d1d5db' }, + labelRenderedSizeThreshold: 12, + labelDensity: 0.12, + zIndex: true, + minCameraRatio: 0.03, + maxCameraRatio: 3, + stagePadding: 30, + defaultNodeBorderSize: 1, + defaultNodeBorderColor: 'rgba(255, 255, 255, 0.06)', + hideEdgesOnMove: true, +} + +function LoadAndDisplay({ graph }: { graph: Graph }) { + const loadGraph = useLoadGraph() + const sigma = useSigma() + + useEffect(() => { + loadGraph(graph) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + sigma.getCamera().animatedReset({ duration: 400 }) + }) + }) + }, [graph, loadGraph, sigma]) + + return null +} + +// Builds the neighbor set for a given node, used by the reducer +function getNeighborSet(sigma: ReturnType, nodeId: string): Set | null { + const graph = sigma.getGraph() + if (!graph.hasNode(nodeId)) return null + const neighbors = new Set(graph.neighbors(nodeId)) + neighbors.add(nodeId) + return neighbors +} + +function Interactions({ + onSelectFile, + pinnedNode, + setPinnedNode, +}: { + onSelectFile?: (filePath: string | null) => void + pinnedNode: string | null + setPinnedNode: (node: string | null) => 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) + // 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' + }, + leaveNode: () => { + setHoveredNode(null) + setTooltip(null) + const el = sigma.getContainer() + if (el) el.style.cursor = 'default' + }, + clickNode: ({ node }) => { + // pin this node and open impact panel + setPinnedNode(node) + onSelectFile?.(node) + }, + doubleClickNode: ({ node }) => { + // 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: () => { + setPinnedNode(null) + onSelectFile?.(null) + }, + }) + }, [registerEvents, sigma, onSelectFile, setPinnedNode]) + + // the active node is: hovered takes priority for visual, but pinned persists + const activeNode = hoveredNode || pinnedNode + + useEffect(() => { + if (!activeNode) { + sigma.setSetting('nodeReducer', null) + sigma.setSetting('edgeReducer', null) + return + } + + const neighbors = getNeighborSet(sigma, activeNode) + if (!neighbors) { + sigma.setSetting('nodeReducer', null) + sigma.setSetting('edgeReducer', null) + return + } + + sigma.setSetting('nodeReducer', (node, data) => { + if (neighbors.has(node)) { + return { + ...data, + zIndex: 1, + label: data.label, + borderSize: node === activeNode ? 3 : 1, + borderColor: node === activeNode ? '#ffffff' : 'rgba(255,255,255,0.15)', + } + } + return { ...data, color: 'rgba(31, 41, 55, 0.25)', label: '', zIndex: 0, borderSize: 0 } + }) + + sigma.setSetting('edgeReducer', (edge, data) => { + const graph = sigma.getGraph() + 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.5)', size: 1.5 } + } + return { ...data, hidden: true } + }) + }, [activeNode, sigma]) + + const tooltipData = (() => { + if (!tooltip) return null + const graph = sigma.getGraph() + if (!graph.hasNode(tooltip.nodeId)) return null + const a = graph.getNodeAttributes(tooltip.nodeId) + return { + 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) + const [pinnedNode, setPinnedNode] = useState(null) + + if (!graph || graph.order === 0) { + return ( +
+ No graph data available +
+ ) + } + + return ( +
+ + + + { + setPinnedNode(nodeId) + if (nodeId) onSelectFile?.(nodeId) + }} + /> + + + +
+
Legend
+
+
Node size = dependents count
+
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 new file mode 100644 index 0000000..c4fc588 --- /dev/null +++ b/frontend/src/components/DependencyGraph/GraphView/useGraphData.ts @@ -0,0 +1,132 @@ +// 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 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 -- 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, + 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', + size: 0.5, + color: 'rgba(75, 85, 99, 0.12)', + }) + } + } + } + + // 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] + ) + }) + } + + // ForceAtlas2 for layout -- tuned for readability over compactness + forceAtlas2.assign(graph, { + iterations: 400, + settings: { + gravity: 0.5, + scalingRatio: 20, + barnesHutOptimize: graph.order > 50, + barnesHutTheta: 0.5, + slowDown: 3, + strongGravityMode: false, + adjustSizes: true, + linLogMode: true, + outboundAttractionDistribution: 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..7c06d65 --- /dev/null +++ b/frontend/src/components/DependencyGraph/MatrixView/index.tsx @@ -0,0 +1,291 @@ +// Dependency Structure Matrix -- directory level by default, drill into files +// Shows cross-directory coupling at a glance, click to explore within a directory + +import { useState, useMemo, useCallback } from 'react' +import { ArrowLeft } from 'lucide-react' +import { useDirectoryMatrix, useFileMatrix } from './useMatrixData' +import type { DependencyApiResponse } from '../types' + +interface MatrixViewProps { + data: DependencyApiResponse + onSelectFile?: (filePath: string) => void +} + +function getCellBg(value: number, isCycle: boolean, isDiagonal: boolean): string { + if (isDiagonal) return 'bg-zinc-800/40' + if (isCycle) return 'bg-rose-500/60 hover:bg-rose-500/80' + if (value === 0) return 'hover:bg-zinc-800/30' + if (value <= 2) return 'bg-indigo-500/20 hover:bg-indigo-500/30' + if (value <= 5) return 'bg-indigo-500/35 hover:bg-indigo-500/50' + if (value <= 10) return 'bg-indigo-500/50 hover:bg-indigo-500/65' + return 'bg-indigo-500/70 hover:bg-indigo-500/80' +} + +function MatrixGrid({ + labels, + matrix, + cycles, + onCellHover, + onRowClick, + highlightedIndex, + setHighlightedIndex, + cycleSet, +}: { + labels: string[] + matrix: 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 size = labels.length + // cell size scales with matrix size for readability + const cellSize = size <= 15 ? 36 : size <= 30 ? 28 : size <= 50 ? 20 : 14 + + return ( +
+ + + + + ))} + + + + {matrix.map((row, rowIdx) => ( + + + {row.map((value, colIdx) => { + const isCycle = cycleSet.has(`${rowIdx}-${colIdx}`) + const isDiagonal = rowIdx === colIdx + const isHighlighted = highlightedIndex === rowIdx || highlightedIndex === colIdx + + return ( + + ) + })} + + ))} + +
+ {labels.map((label, col) => ( + setHighlightedIndex(col)} + onMouseLeave={() => setHighlightedIndex(null)} + onClick={() => onRowClick(col)} + > +
+ {label} +
+
onRowClick(rowIdx)} + onMouseEnter={() => setHighlightedIndex(rowIdx)} + onMouseLeave={() => setHighlightedIndex(null)} + > + {labels[rowIdx]} + { + if (value > 0 || isCycle) { + onCellHover({ row: rowIdx, col: colIdx, x: e.clientX, y: e.clientY }) + } else { + onCellHover(null) + } + }} + onMouseLeave={() => onCellHover(null)} + > + {value > 0 && !isDiagonal && ( + {value} + )} +
+
+ ) +} + +function CellTooltip({ + source, + target, + count, + isCycle, + position, +}: { + source: string + target: string + count: number + isCycle: boolean + position: { x: number; y: number } +}) { + return ( +
+
{source}
+
+ {count} import{count !== 1 ? 's' : ''} from +
+
{target}
+ {isCycle && ( +
Circular dependency detected
+ )} +
+ ) +} + +export function MatrixView({ data, onSelectFile }: MatrixViewProps) { + const [drillDir, setDrillDir] = useState(null) + const [hoveredCell, setHoveredCell] = useState<{ + row: number; col: number; x: number; y: number + } | null>(null) + const [highlightedIndex, setHighlightedIndex] = useState(null) + + const dirMatrix = useDirectoryMatrix(data) + const fileMatrix = useFileMatrix(data, drillDir) + + const isDrilled = drillDir !== null && fileMatrix !== null + + // which matrix to show + const activeLabels = isDrilled ? fileMatrix.shortLabels : dirMatrix?.shortLabels || [] + const activeFullLabels = isDrilled ? fileMatrix.files : dirMatrix?.directories || [] + const activeMatrix = isDrilled ? fileMatrix.matrix : dirMatrix?.matrix || [] + const activeCycles = isDrilled ? fileMatrix.cycles : dirMatrix?.cycles || [] + + const handleRowClick = useCallback((index: number) => { + if (isDrilled) { + // file level: trigger impact analysis + const filePath = fileMatrix!.files[index] + onSelectFile?.(filePath) + } else if (dirMatrix) { + // directory level: drill into that directory + const dir = dirMatrix.directories[index] + setDrillDir(dir) + } + }, [isDrilled, fileMatrix, dirMatrix, onSelectFile]) + + const cycleSet = useMemo(() => { + const set = new Set() + for (const [a, b] of activeCycles) { + set.add(`${a}-${b}`) + set.add(`${b}-${a}`) + } + return set + }, [activeCycles]) + + if (!dirMatrix) { + return ( +
+ No dependency data for matrix view +
+ ) + } + + return ( +
+ {/* header */} +
+ {isDrilled && ( + + )} +
+ {isDrilled ? ( + <> + {drillDir}/ + {fileMatrix!.files.length} files + + ) : ( + <> + {dirMatrix.directories.length} directories + {dirMatrix.totalDeps} dependencies + {dirMatrix.totalCycles > 0 && ( + + {dirMatrix.totalCycles} circular {dirMatrix.totalCycles === 1 ? 'dep' : 'deps'} + + )} + + )} +
+
+ + {!isDrilled && ( +
+ Click a directory to see file-level dependencies within it +
+ )} + + {/* matrix */} + + + {/* legend */} +
+
+
+ 1-2 +
+
+
+ 3-5 +
+
+
+ 6-10 +
+
+
+ 10+ +
+
+
+ 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 new file mode 100644 index 0000000..7a7bd4a --- /dev/null +++ b/frontend/src/components/DependencyGraph/MatrixView/useMatrixData.ts @@ -0,0 +1,147 @@ +// Transforms API response into directory-level matrix (default) +// and file-level matrix (drill-down on directory click) + +import { useMemo } from 'react' +import type { DependencyApiResponse } from '../types' + +function getDirectory(filePath: string): string { + const parts = filePath.split('/') + return parts.length > 1 ? parts.slice(0, -1).join('/') : '.' +} + +function getShortDir(dirPath: string): string { + const parts = dirPath.split('/') + return parts[parts.length - 1] || dirPath +} + +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 + + // 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 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 deps between directories + 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]) + } + } + } + + // count cross-directory deps only (off-diagonal) + let crossDirDeps = 0 + for (let i = 0; i < size; i++) { + for (let j = 0; j < size; j++) { + if (i !== j) crossDirDeps += matrix[i][j] + } + } + + return { + directories, + shortLabels: directories.map(getShortDir), + matrix, + fileCounts, + cycles, + totalDeps: crossDirDeps, + totalCycles: cycles.length, + } + }, [apiData]) +} + +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)) + + 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]++ + } + } + + 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 { + directory, + files, + shortLabels: files.map((f) => f.split('/').pop() || f), + matrix, + cycles, + } + }, [apiData, directory]) +} diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx index 6eb0044..c0cfbe5 100644 --- a/frontend/src/components/DependencyGraph/index.tsx +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -1,605 +1,149 @@ -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 - apiUrl: string 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, 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} - /> +
+ {viewMode === 'graph' && ( + setSelectedFile(f)} /> + )} + {viewMode === 'matrix' && ( + setSelectedFile(f)} /> + )} -
-
- - - + setSelectedFile(null)} + onFileClick={(fileId) => setSelectedFile(fileId)} + onFileHover={() => {}} /> - - - {/* 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} - /> +
)}
) } -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..246c8cf --- /dev/null +++ b/frontend/src/components/DependencyGraph/types.ts @@ -0,0 +1,34 @@ +// 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 +} 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' && ( )}