diff --git a/package.json b/package.json index fdec14b..7631829 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,14 @@ }, "dependencies": { "@electron-toolkit/utils": "^4.0.0", + "@types/d3": "^7.4.3", "@types/plist": "^3.0.5", "adm-zip": "^0.5.16", "axios": "^1.14.0", "chokidar": "^5.0.0", "croner": "^9.1.0", "crypto-js": "^4.2.0", + "d3": "^7.9.0", "express": "^5.2.1", "file-icon": "^6.0.0", "file-icon-info": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44b01ba..958b396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@37.10.0) + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 '@types/plist': specifier: ^3.0.5 version: 3.0.5 @@ -29,6 +32,9 @@ importers: crypto-js: specifier: ^4.2.0 version: 4.2.0 + d3: + specifier: ^7.9.0 + version: 7.9.0 express: specifier: ^5.2.1 version: 5.2.1 @@ -2173,6 +2179,99 @@ packages: '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2191,6 +2290,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -2691,6 +2793,10 @@ packages: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commander@9.5.0: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} @@ -2760,6 +2866,133 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2828,6 +3061,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3536,6 +3772,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} @@ -4598,6 +4838,9 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup@4.60.0: resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4607,6 +4850,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -7888,6 +8134,123 @@ snapshots: '@types/crypto-js@4.2.2': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -7915,6 +8278,8 @@ snapshots: dependencies: '@types/node': 25.5.0 + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -8540,6 +8905,8 @@ snapshots: commander@5.1.0: {} + commander@7.2.0: {} + commander@9.5.0: optional: true @@ -8593,6 +8960,158 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + data-uri-to-buffer@4.0.1: {} data-view-buffer@1.0.2: @@ -8655,6 +9174,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -9654,6 +10177,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + intl-messageformat@10.7.18: dependencies: '@formatjs/ecma402-abstract': 2.3.6 @@ -10833,6 +11358,8 @@ snapshots: sprintf-js: 1.1.3 optional: true + robust-predicates@3.0.3: {} + rollup@4.60.0: dependencies: '@types/estree': 1.0.8 @@ -10874,6 +11401,8 @@ snapshots: transitivePeerDependencies: - supports-color + rw@1.3.3: {} + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 diff --git a/src/renderer/src/components/network/network-topology.tsx b/src/renderer/src/components/network/network-topology.tsx new file mode 100644 index 0000000..1863798 --- /dev/null +++ b/src/renderer/src/components/network/network-topology.tsx @@ -0,0 +1,620 @@ +import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react' +import * as d3 from 'd3' +import { Button } from '@heroui/react' +import { IoPauseOutline, IoPlayOutline, IoGitNetworkOutline } from 'react-icons/io5' +import { + IoDesktopOutline, + IoServerOutline, + IoFunnelOutline +} from 'react-icons/io5' +import { MdTag } from 'react-icons/md' +import { useTranslation } from 'react-i18next' +import { useTheme } from 'next-themes' +import { calcTraffic } from '@renderer/utils/calc' + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type NodeType = 'root' | 'client' | 'port' | 'rule' | 'group' | 'proxy' + +interface TopologyNodeData { + id: string + name: string + type: NodeType + connections: number + traffic: number + children?: TopologyNodeData[] + _children?: TopologyNodeData[] + collapsed?: boolean +} + +// ─── Color helpers ──────────────────────────────────────────────────────────── + +function getNodeColors() { + return { + root: { + fill: 'hsl(var(--heroui-default-500))', + bg: 'hsl(var(--heroui-default-500) / 0.15)' + }, + client: { + fill: 'hsl(var(--heroui-primary))', + bg: 'hsl(var(--heroui-primary) / 0.15)' + }, + port: { + fill: 'hsl(var(--heroui-warning))', + bg: 'hsl(var(--heroui-warning) / 0.15)' + }, + rule: { + fill: 'hsl(var(--heroui-secondary))', + bg: 'hsl(var(--heroui-secondary) / 0.15)' + }, + group: { + fill: 'hsl(var(--heroui-success))', + bg: 'hsl(var(--heroui-success) / 0.15)' + }, + proxy: { + fill: 'hsl(var(--heroui-danger))', + bg: 'hsl(var(--heroui-danger) / 0.15)' + }, + baseContent: 'hsl(var(--heroui-foreground))' + } +} + +// ─── Hierarchy builder ──────────────────────────────────────────────────────── + +function buildHierarchy( + connections: IMihomoConnectionDetail[], + collapsedNodes: Set +): TopologyNodeData { + const groupsMap = new Map< + string, + { + data: TopologyNodeData + proxies: Map< + string, + { + data: TopologyNodeData + rules: Map< + string, + { + data: TopologyNodeData + clients: Map }> + } + > + } + > + } + >() + + for (const conn of connections) { + const clientIP = conn.metadata.sourceIP || 'Unknown' + const sourcePort = String(conn.metadata.sourcePort || 'Unknown') + const ruleType = conn.rule || 'Direct' + const fullRule = conn.rulePayload ? `${ruleType}: ${conn.rulePayload}` : ruleType + const chains = conn.chains || [] + const proxy = chains[0] ?? 'Direct' + const group = chains.length > 1 ? (chains[1] ?? 'Direct') : (chains[0] ?? 'Direct') + const traffic = conn.download + conn.upload + + if (!groupsMap.has(group)) { + groupsMap.set(group, { + data: { id: `group-${group}`, name: group, type: 'group', connections: 0, traffic: 0 }, + proxies: new Map() + }) + } + const groupEntry = groupsMap.get(group)! + groupEntry.data.connections++ + groupEntry.data.traffic += traffic + + if (!groupEntry.proxies.has(proxy)) { + groupEntry.proxies.set(proxy, { + data: { + id: `proxy-${group}-${proxy}`, + name: proxy, + type: 'proxy', + connections: 0, + traffic: 0 + }, + rules: new Map() + }) + } + const proxyEntry = groupEntry.proxies.get(proxy)! + proxyEntry.data.connections++ + proxyEntry.data.traffic += traffic + + if (!proxyEntry.rules.has(fullRule)) { + proxyEntry.rules.set(fullRule, { + data: { + id: `rule-${group}-${proxy}-${fullRule}`, + name: fullRule, + type: 'rule', + connections: 0, + traffic: 0 + }, + clients: new Map() + }) + } + const ruleEntry = proxyEntry.rules.get(fullRule)! + ruleEntry.data.connections++ + ruleEntry.data.traffic += traffic + + if (!ruleEntry.clients.has(clientIP)) { + ruleEntry.clients.set(clientIP, { + data: { + id: `client-${group}-${proxy}-${fullRule}-${clientIP}`, + name: clientIP, + type: 'client', + connections: 0, + traffic: 0 + }, + ports: new Map() + }) + } + const clientEntry = ruleEntry.clients.get(clientIP)! + clientEntry.data.connections++ + clientEntry.data.traffic += traffic + + if (!clientEntry.ports.has(sourcePort)) { + clientEntry.ports.set(sourcePort, { + id: `port-${group}-${proxy}-${fullRule}-${clientIP}-${sourcePort}`, + name: sourcePort, + type: 'port', + connections: 0, + traffic: 0 + }) + } + const portNode = clientEntry.ports.get(sourcePort)! + portNode.connections++ + portNode.traffic += traffic + } + + // Convert to tree with collapse state + const rootChildren: TopologyNodeData[] = [] + + function applyCollapse(node: TopologyNodeData, defaultCollapsed = false): TopologyNodeData { + const isCollapsed = collapsedNodes.has(node.id) || defaultCollapsed + if (isCollapsed && node.children && node.children.length > 0) { + return { ...node, _children: node.children, children: undefined, collapsed: true } + } + return { ...node, collapsed: false } + } + + groupsMap.forEach((groupEntry) => { + const groupNode: TopologyNodeData = { ...groupEntry.data, children: [] } + + groupEntry.proxies.forEach((proxyEntry) => { + const proxyNode: TopologyNodeData = { ...proxyEntry.data, children: [] } + + proxyEntry.rules.forEach((ruleEntry) => { + const ruleNode: TopologyNodeData = { ...ruleEntry.data, children: [] } + + ruleEntry.clients.forEach((clientEntry) => { + const portChildren = Array.from(clientEntry.ports.values()) + const isClientCollapsed = !collapsedNodes.has(`expanded-${clientEntry.data.id}`) + const clientNode: TopologyNodeData = isClientCollapsed + ? { ...clientEntry.data, _children: portChildren, children: undefined, collapsed: true } + : { ...clientEntry.data, children: portChildren, collapsed: false } + ruleNode.children!.push(clientNode) + }) + + const isRuleCollapsed = !collapsedNodes.has(`expanded-${ruleEntry.data.id}`) + if (isRuleCollapsed && ruleNode.children!.length > 0) { + ruleNode._children = ruleNode.children + ruleNode.children = undefined + ruleNode.collapsed = true + } else { + ruleNode.collapsed = false + } + proxyNode.children!.push(ruleNode) + }) + + groupNode.children!.push(applyCollapse(proxyNode)) + }) + + rootChildren.push(applyCollapse(groupNode)) + }) + + return { + id: 'root', + name: 'Connections', + type: 'root', + connections: connections.length, + traffic: connections.reduce((s, c) => s + c.download + c.upload, 0), + children: rootChildren + } +} + +// ─── Text measurement ───────────────────────────────────────────────────────── + +let measureCanvas: HTMLCanvasElement | null = null +function getTextWidth(text: string, font = '600 11px sans-serif'): number { + if (!measureCanvas) measureCanvas = document.createElement('canvas') + const ctx = measureCanvas.getContext('2d') + if (!ctx) return text.length * 7 + ctx.font = font + return ctx.measureText(text).width +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +const NetworkTopologyCard: React.FC = () => { + const { t } = useTranslation() + const { resolvedTheme } = useTheme() + + const svgRef = useRef(null) + const containerRef = useRef(null) + + const [connections, setConnections] = useState([]) + const [isPaused, setIsPaused] = useState(false) + const frozenRef = useRef(null) + const [collapsedNodes, setCollapsedNodes] = useState>(new Set()) + + // IPC listener + useEffect(() => { + if (isPaused) return + const handler = (_e: unknown, ...args: unknown[]): void => { + const info = args[0] as IMihomoConnectionsInfo + setConnections(info.connections ?? []) + } + window.electron.ipcRenderer.on('mihomoConnections', handler) + return () => { + window.electron.ipcRenderer.removeAllListeners('mihomoConnections') + } + }, [isPaused]) + + const currentConnections = isPaused && frozenRef.current ? frozenRef.current : connections + + // Stats + const stats = useMemo(() => { + const clients = new Set() + const rules = new Set() + const groups = new Set() + const proxies = new Set() + for (const c of currentConnections) { + clients.add(c.metadata.sourceIP || 'Unknown') + rules.add(c.rule || 'Direct') + const ch = c.chains || [] + proxies.add(ch[0] ?? 'Direct') + groups.add(ch.length > 1 ? (ch[1] ?? 'Direct') : (ch[0] ?? 'Direct')) + } + return { + clientCount: clients.size, + ruleCount: rules.size, + groupCount: groups.size, + proxyCount: proxies.size, + totalTraffic: currentConnections.reduce((s, c) => s + c.download + c.upload, 0) + } + }, [currentConnections]) + + // Hierarchy data + const hierarchyData = useMemo( + () => buildHierarchy(currentConnections, collapsedNodes), + [currentConnections, collapsedNodes] + ) + + // Toggle collapse + const toggleCollapseRef = useRef<(nodeId: string, isCollapsed: boolean) => void>(() => {}) + toggleCollapseRef.current = useCallback( + (nodeId: string, isCurrentlyCollapsed: boolean) => { + const expandedKey = `expanded-${nodeId}` + setCollapsedNodes((prev) => { + const next = new Set(prev) + if (isCurrentlyCollapsed) { + if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) { + next.add(expandedKey) + } else { + next.delete(nodeId) + } + } else { + if (nodeId.startsWith('rule-') || nodeId.startsWith('client-')) { + next.delete(expandedKey) + } else { + next.add(nodeId) + } + } + return next + }) + }, + [] + ) + + // D3 render + useEffect(() => { + const svgEl = svgRef.current + const containerEl = containerRef.current + if (!svgEl || !containerEl) return + if (!hierarchyData.children || hierarchyData.children.length === 0) { + d3.select(svgEl).selectAll('*').remove() + return + } + + d3.select(svgEl).selectAll('*').remove() + + const nodeHeight = 30 + const nodePaddingX = 16 + const nodeSpacingY = 50 + const levelGap = 40 + const topPadding = 25 + + const root = d3.hierarchy(hierarchyData) + const containerWidth = containerEl.clientWidth + + const treeLayout = d3 + .tree() + .nodeSize([nodeSpacingY, 100]) + .separation(() => 1) + + treeLayout(root) + + // Calculate node widths + const nodeWidths = new Map() + for (const d of root.descendants()) { + if (d.data.type !== 'root') { + const textWidth = getTextWidth(d.data.name) + const hasChildren = + (d.data.children && d.data.children.length > 0) || + (d.data._children && d.data._children.length > 0) + nodeWidths.set(d.data.id, textWidth + nodePaddingX * 2 + (hasChildren ? 20 : 0)) + } + } + + const getNodeWidth = (d: d3.HierarchyNode) => + nodeWidths.get(d.data.id) ?? 80 + + // Max width per depth + const maxWidthPerLevel = new Map() + root.descendants().forEach((d) => { + if (d.data.type !== 'root') { + const w = getNodeWidth(d) + maxWidthPerLevel.set(d.depth, Math.max(maxWidthPerLevel.get(d.depth) ?? 0, w)) + } + }) + + // Cumulative x offsets + const levelXOffset = new Map() + let cumX = 0 + for (let depth = 1; depth <= maxWidthPerLevel.size; depth++) { + const curW = maxWidthPerLevel.get(depth) ?? 100 + if (depth === 1) { + cumX = curW / 2 + } else { + const prevW = maxWidthPerLevel.get(depth - 1) ?? 100 + cumX += prevW / 2 + levelGap + curW / 2 + } + levelXOffset.set(depth, cumX) + } + + root.descendants().forEach((d) => { + if (d.data.type !== 'root' && d.depth > 0) { + d.y = levelXOffset.get(d.depth) ?? d.y + } + }) + + // Bounds + let minX = Infinity + let maxX = -Infinity + root.each((d) => { + if ((d.x ?? 0) < minX) minX = d.x ?? 0 + if ((d.x ?? 0) > maxX) maxX = d.x ?? 0 + }) + + const treeHeight = maxX - minX + nodeSpacingY + const actualHeight = Math.max(400, treeHeight + topPadding + 40) + + let maxY = 0 + root.each((d) => { + if (d.data.type !== 'root') { + const rightEdge = (d.y ?? 0) + getNodeWidth(d) / 2 + if (rightEdge > maxY) maxY = rightEdge + } + }) + const actualWidth = Math.max(containerWidth, maxY + 60 + 40) + + const svg = d3.select(svgEl).attr('width', actualWidth).attr('height', actualHeight) + + const g = svg + .append('g') + .attr('transform', `translate(60, ${-minX + nodeSpacingY / 2 + topPadding})`) + + const colors = getNodeColors() + + // Links + const visibleLinks = root.links().filter((l) => l.source.data.type !== 'root') + g.selectAll('.link') + .data(visibleLinks) + .join('path') + .attr('class', 'link') + .attr('d', (d) => { + const src = d.source as d3.HierarchyPointNode + const tgt = d.target as d3.HierarchyPointNode + const sx = src.y + getNodeWidth(src) / 2 + const sy = src.x + const tx = tgt.y - getNodeWidth(tgt) / 2 + const ty = tgt.x + const mx = (sx + tx) / 2 + return `M${sx},${sy} C${mx},${sy} ${mx},${ty} ${tx},${ty}` + }) + .attr('fill', 'none') + .style('stroke', colors.baseContent) + .attr('stroke-opacity', 0.3) + .attr('stroke-width', (d) => Math.max(1, Math.min(4, d.target.data.connections / 5))) + + // Nodes + const nodes = g + .selectAll('.node') + .data(root.descendants().filter((d) => d.data.type !== 'root')) + .join('g') + .attr('class', 'node') + .attr('transform', (d) => `translate(${d.y ?? 0},${d.x})`) + .style('cursor', (d) => { + const hasChildren = + (d.data.children && d.data.children.length > 0) || + (d.data._children && d.data._children.length > 0) + return hasChildren ? 'pointer' : 'default' + }) + .on('click', (_event, d) => { + const hasChildren = + (d.data.children && d.data.children.length > 0) || + (d.data._children && d.data._children.length > 0) + if (hasChildren) { + toggleCollapseRef.current(d.data.id, d.data.collapsed ?? false) + } + }) + + // Connection count badge + nodes + .append('text') + .attr('dy', -nodeHeight / 2 - 4) + .attr('text-anchor', 'middle') + .style('fill', (d) => colors[d.data.type].fill) + .attr('font-size', '10px') + .attr('font-weight', '500') + .text((d) => `${d.data.connections}`) + + // Node rect + nodes + .append('rect') + .attr('x', (d) => -getNodeWidth(d) / 2) + .attr('y', -nodeHeight / 2) + .attr('width', (d) => getNodeWidth(d)) + .attr('height', nodeHeight) + .attr('rx', 6) + .style('fill', (d) => colors[d.data.type].bg) + .style('stroke', (d) => colors[d.data.type].fill) + .attr('stroke-width', 2) + + // Collapse indicator + nodes + .filter((d) => + Boolean( + (d.data.children && d.data.children.length > 0) || + (d.data._children && d.data._children.length > 0) + ) + ) + .append('text') + .attr('x', (d) => getNodeWidth(d) / 2 - 12) + .attr('dy', '0.35em') + .attr('text-anchor', 'middle') + .style('fill', (d) => colors[d.data.type].fill) + .attr('font-size', '14px') + .attr('font-weight', '700') + .text((d) => (d.data.collapsed ? '+' : '−')) + + // Label + nodes + .append('text') + .attr('dy', '0.31em') + .attr('text-anchor', 'middle') + .style('fill', (d) => colors[d.data.type].fill) + .attr('font-size', '11px') + .attr('font-weight', '600') + .text((d) => d.data.name) + + // Tooltip + nodes + .append('title') + .text( + (d) => + `${d.data.name}\n${d.data.connections} connections\n${calcTraffic(d.data.traffic)}` + ) + }, [hierarchyData, resolvedTheme]) + + // ResizeObserver + useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver(() => { + // Re-trigger render by forcing a state touch isn't ideal; + // instead just re-run the render imperatively + if (svgRef.current && containerRef.current) { + svgRef.current.setAttribute('width', String(containerRef.current.clientWidth)) + } + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const togglePause = useCallback(() => { + if (isPaused) { + frozenRef.current = null + } else { + frozenRef.current = [...connections] + } + setIsPaused((p) => !p) + }, [isPaused, connections]) + + return ( +
+ {/* Header */} +
+
+
+ +
+

{t('network.topology.title')}

+
+
+ {/* Stats */} +
+ {stats.clientCount} {t('network.topology.clients')} + · + {stats.ruleCount} {t('network.topology.rules')} + · + {stats.groupCount} {t('network.topology.groups')} + · + {stats.proxyCount} {t('network.topology.nodes')} + · + {calcTraffic(stats.totalTraffic)} +
+ +
+
+ + {/* Legend */} +
+ + + {t('network.topology.proxyGroups')} + + + + {t('network.topology.proxyNodes')} + + + + {t('network.topology.rules')} + + + + {t('network.topology.sourceIP')} + + + + {t('network.topology.sourcePort')} + +
+ + {/* Empty state */} + {currentConnections.length === 0 ? ( +
+ + {t('network.topology.waiting')} +
+ ) : ( +
+ +
+ )} +
+ ) +} + +export default NetworkTopologyCard diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index 75413ce..9185e5c 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -695,5 +695,17 @@ "guide.dns.title": "DNS OVRD", "guide.dns.description": "The software defaults to using the application's DNS settings to override the subscription configuration. If you need to use the DNS settings from the subscription configuration, please disable this feature. The same applies to domain sniffing.", "guide.end.title": "Tutorial Complete", - "guide.end.description": "Now that you understand the basic usage of the software, import your subscription and start using it. Enjoy!\nYou can also join our official Telegram group for the latest news." -} + "guide.end.description": "Now that you understand the basic usage of the software, import your subscription and start using it. Enjoy!\nYou can also join our official Telegram group for the latest news.", + "network.topology.title": "Network Topology", + "network.topology.clients": "clients", + "network.topology.rules": "Rules", + "network.topology.groups": "groups", + "network.topology.nodes": "nodes", + "network.topology.proxyGroups": "Proxy Groups", + "network.topology.proxyNodes": "Proxy Nodes", + "network.topology.sourceIP": "Source IP", + "network.topology.sourcePort": "Source Port", + "network.topology.waiting": "Waiting for connections...", + "network.topology.pause": "Pause", + "network.topology.resume": "Resume" +} \ No newline at end of file diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index aa82d27..0c1c8e9 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -659,5 +659,17 @@ "guide.dns.title": "DNS", "guide.dns.description": "نرم‌افزار به‌طور پیش‌فرض از تنظیمات DNS برنامه برای بازنویسی پیکربندی اشتراک استفاده می‌کند. اگر نیاز به استفاده از تنظیمات DNS موجود در پیکربندی اشتراک دارید، لطفاً این قابلیت را غیرفعال کنید. همین موضوع برای شناسایی دامنه نیز صدق می‌کند.", "guide.end.title": "پایان آموزش", - "guide.end.description": "اکنون که با استفاده اساسی از نرم‌افزار آشنا شدید، اشتراک خود را وارد کنید و از آن استفاده کنید. لذت ببرید!\nهمچنین می‌توانید برای آخرین اخبار به گروه تلگرام ما بپیوندید." -} + "guide.end.description": "اکنون که با استفاده اساسی از نرم‌افزار آشنا شدید، اشتراک خود را وارد کنید و از آن استفاده کنید. لذت ببرید!\nهمچنین می‌توانید برای آخرین اخبار به گروه تلگرام ما بپیوندید.", + "network.topology.title": "توپولوژی شبکه", + "network.topology.clients": "کلاینت", + "network.topology.rules": "قوانین", + "network.topology.groups": "گروه‌ها", + "network.topology.nodes": "گره‌ها", + "network.topology.proxyGroups": "گروه‌های پروکسی", + "network.topology.proxyNodes": "گره‌های پروکسی", + "network.topology.sourceIP": "IP منبع", + "network.topology.sourcePort": "پورت منبع", + "network.topology.waiting": "در انتظار اتصال...", + "network.topology.pause": "مکث", + "network.topology.resume": "ادامه" +} \ No newline at end of file diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index a2d033c..54c1c96 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -661,5 +661,17 @@ "guide.dns.title": "DNS", "guide.dns.description": "Программное обеспечение по умолчанию использует настройки DNS приложения для переопределения конфигурации подписки. Если вам нужно использовать настройки DNS из конфигурации подписки, пожалуйста, отключите эту функцию. То же самое относится к определению домена.", "guide.end.title": "Руководство завершено", - "guide.end.description": "Теперь, когда вы понимаете основы использования программы, импортируйте свою подписку и начните использовать ее. Приятного использования!\nВы также можете присоединиться к нашей официальной группе Telegram для получения последних новостей." -} + "guide.end.description": "Теперь, когда вы понимаете основы использования программы, импортируйте свою подписку и начните использовать ее. Приятного использования!\nВы также можете присоединиться к нашей официальной группе Telegram для получения последних новостей.", + "network.topology.title": "Топология сети", + "network.topology.clients": "клиентов", + "network.topology.rules": "правил", + "network.topology.groups": "групп", + "network.topology.nodes": "узлов", + "network.topology.proxyGroups": "Группы прокси", + "network.topology.proxyNodes": "Узлы прокси", + "network.topology.sourceIP": "IP источника", + "network.topology.sourcePort": "Порт источника", + "network.topology.waiting": "Ожидание подключений...", + "network.topology.pause": "Пауза", + "network.topology.resume": "Возобновить" +} \ No newline at end of file diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 43309fd..533a3d1 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -695,5 +695,17 @@ "guide.dns.title": "DNS", "guide.dns.description": "软件默认使用应用的 DNS 设置覆盖订阅配置,如果您需要使用订阅配置中的 DNS 设置,请关闭此功能,域名嗅探同理", "guide.end.title": "教程结束", - "guide.end.description": "现在您已经了解了软件的基本用法,导入您的订阅开始使用吧,祝您使用愉快!\n您还可以加入我们的官方 Telegram 群组 获取最新资讯" -} + "guide.end.description": "现在您已经了解了软件的基本用法,导入您的订阅开始使用吧,祝您使用愉快!\n您还可以加入我们的官方 Telegram 群组 获取最新资讯", + "network.topology.title": "网络拓扑", + "network.topology.clients": "客户端", + "network.topology.rules": "规则", + "network.topology.groups": "代理组", + "network.topology.nodes": "节点", + "network.topology.proxyGroups": "代理组", + "network.topology.proxyNodes": "代理节点", + "network.topology.sourceIP": "来源 IP", + "network.topology.sourcePort": "来源端口", + "network.topology.waiting": "等待连接数据...", + "network.topology.pause": "暂停", + "network.topology.resume": "恢复" +} \ No newline at end of file diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 0ce6f31..3ee0358 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -695,5 +695,17 @@ "guide.dns.title": "DNS", "guide.dns.description": "軟件默認使用應用程式的 DNS 設置覆蓋訂閱配置,如果您需要使用訂閱配置中的 DNS 設置,請關閉此功能,域名嗅探同理", "guide.end.title": "教程結束", - "guide.end.description": "現在您已經瞭解了軟件的基本用法,匯入您的訂閱開始使用吧,祝您使用愉快!\n您還可以加入我們的官方 Telegram 群組 獲取最新資訊" -} + "guide.end.description": "現在您已經瞭解了軟件的基本用法,匯入您的訂閱開始使用吧,祝您使用愉快!\n您還可以加入我們的官方 Telegram 群組 獲取最新資訊", + "network.topology.title": "網路拓撲", + "network.topology.clients": "客戶端", + "network.topology.rules": "規則", + "network.topology.groups": "代理群組", + "network.topology.nodes": "節點", + "network.topology.proxyGroups": "代理群組", + "network.topology.proxyNodes": "代理節點", + "network.topology.sourceIP": "來源 IP", + "network.topology.sourcePort": "來源連接埠", + "network.topology.waiting": "等待連線資料...", + "network.topology.pause": "暫停", + "network.topology.resume": "繼續" +} \ No newline at end of file diff --git a/src/renderer/src/pages/network.tsx b/src/renderer/src/pages/network.tsx index ea0dce2..1db9436 100644 --- a/src/renderer/src/pages/network.tsx +++ b/src/renderer/src/pages/network.tsx @@ -1,4 +1,5 @@ import BasePage from '@renderer/components/base/base-page' +import NetworkTopologyCard from '@renderer/components/network/network-topology' import React, { useState, useEffect, useCallback } from 'react' import { Button, Select, SelectItem, Chip, Tooltip } from '@heroui/react' import { @@ -385,6 +386,9 @@ const IPPage: React.FC = () => { )} + {/* 网络拓扑卡片 */} + + {/* 网络延迟卡片 */}