diff --git a/bun.lock b/bun.lock index a8474ac3d..e5fda065c 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "decode-formdata": "^0.9.0", "deepsignal": "^1.6.0", "dinero.js": "2.0.2", + "ego-graph": "workspace:*", "embla-carousel-react": "^8.6.0", "firebase": "^12.13.0", "i18next": "^26.3.0", @@ -138,6 +139,26 @@ "vitest": "4.1.8", }, }, + "packages/ego-graph": { + "name": "ego-graph", + "version": "0.1.0", + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@dagrejs/dagre": "^3.0.0", + "publint": "^0.3.23", + "tsdown": "^0.22.14", + "typescript": "^5.8.2", + "vite": "^7.3.6", + "vite-tsconfig-paths": "6.1.1", + "vitest": "4.1.8", + }, + "peerDependencies": { + "@dagrejs/dagre": "^3.0.0", + }, + "optionalPeers": [ + "@dagrejs/dagre", + ], + }, "packages/marble-api": { "name": "marble-api", "version": "1.0.0", @@ -321,6 +342,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.4", "", {}, "sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw=="], + "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@14.0.1", "", { "dependencies": { "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw=="], "@apidevtools/openapi-schemas": ["@apidevtools/openapi-schemas@2.1.0", "", {}, "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="], @@ -337,6 +360,10 @@ "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.10.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.15.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA=="], + "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.5", "", { "dependencies": { "@arethetypeswrong/core": "0.18.5", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "./dist/index.js" } }, "sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g=="], + + "@arethetypeswrong/core": ["@arethetypeswrong/core@0.18.5", "", { "dependencies": { "@andrewbranch/untar.js": "^1.0.3", "@loaderkit/resolve": "^1.0.2", "cjs-module-lexer": "^1.2.3", "fflate": "^0.8.3", "lru-cache": "^11.0.1", "semver": "^7.5.4", "typescript": "5.6.1-rc", "validate-npm-package-name": "^5.0.0" } }, "sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ=="], + "@ariakit/components": ["@ariakit/components@0.1.5", "", { "dependencies": { "@ariakit/store": "0.1.4", "@ariakit/utils": "0.1.4" } }, "sha512-LnvU9bcOU2IkQS3jCFm2rxcULNtG55+M+fh66mFf4EEBHJnDoTdN7zNRtm1dyzcFn6hAn/AoLl3Hyg95RIGUOA=="], "@ariakit/react": ["@ariakit/react@0.4.32", "", { "dependencies": { "@ariakit/react-components": "0.3.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ar5lyS/Pp/+p1TdAqsS8gHzL/E9df8uWXYmEsD/Olpv4hp9LVEtwM6FfOu57sn6qaycs2xWFN9GinmXkMd8LWA=="], @@ -421,9 +448,11 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A=="], + "@braidai/lang": ["@braidai/lang@1.1.2", "", {}, "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], - "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], @@ -631,6 +660,8 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@loaderkit/resolve": ["@loaderkit/resolve@1.0.6", "", { "dependencies": { "@braidai/lang": "^1.0.0" } }, "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], @@ -783,7 +814,7 @@ "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], - "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], @@ -853,6 +884,10 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@publint/pack": ["@publint/pack@0.1.6", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA=="], + + "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="], @@ -981,6 +1016,34 @@ "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], @@ -1101,6 +1164,8 @@ "@sentry/vite-plugin": ["@sentry/vite-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" } }, "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.6", "", { "dependencies": { "@solid-primitives/utils": "^6.4.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-5I0YJcTVYIWoMmgBSROBZGcz+ymhew/pGTg2dHW74BUjFKsV8Li4bOZYl0YAGP4mHw5o4UBd9/BEesqBci3wxw=="], @@ -1391,6 +1456,56 @@ "@xyflow/system": ["@xyflow/system@0.0.79", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], + "@yuku-codegen/binding-android-arm64": ["@yuku-codegen/binding-android-arm64@0.8.4", "", { "os": "android", "cpu": "arm64" }, "sha512-rsYkGl2kOkDRsh1mxriYnk1qBS78vjlBJ3+T2XwtwKwqOliy2n+2Ae0EDxJ/uX1DZLm3KkBZarGO5isEnmHchA=="], + + "@yuku-codegen/binding-darwin-arm64": ["@yuku-codegen/binding-darwin-arm64@0.8.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tNLKzPF3FYmEcHSYvWp/LEpjHHAtDR13hwo6/gdCkYMi9x59CWn2obczKzWNe7kDor4/1AMZuJDEVRpFkTSefw=="], + + "@yuku-codegen/binding-darwin-x64": ["@yuku-codegen/binding-darwin-x64@0.8.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-tK7LWzXNb5JbZpnoCNHB0nEhPFss/LwwehM6m/f0oYDan+iZgFZXzdoy80JdE7dVxjTZDIhUNPx8X8xbIdaoxA=="], + + "@yuku-codegen/binding-freebsd-x64": ["@yuku-codegen/binding-freebsd-x64@0.8.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5MUV4d7g2p5Hd8GiXW6ynTRgYjm4Dw4eM2gaWRZ4crkGetzNx+HlPxcEfGtVNPqH5Qaa4Z2REtzdsdgvaE6/Ng=="], + + "@yuku-codegen/binding-linux-arm-gnu": ["@yuku-codegen/binding-linux-arm-gnu@0.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-g6LnHrR0Rfqq5cXs7olwR2+LlVDc876pw7Hh7YXbukVSiBxQkaxqsEO/trO25z2g99zWzgXwoYvQZ1TnwA2wEw=="], + + "@yuku-codegen/binding-linux-arm-musl": ["@yuku-codegen/binding-linux-arm-musl@0.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7XAPHrROPEFuJWXEGZeLZQN4xR8ENQ65+HSCtCHjSzCwGgnX53GUjM9ExVcHopV0a5g4vu57v2wwtyWIM5iNOQ=="], + + "@yuku-codegen/binding-linux-arm64-gnu": ["@yuku-codegen/binding-linux-arm64-gnu@0.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fnBm7NLuuwFXy7F1vRIuyOc+RW9ADUjuCKVGY87Dj8jtr9XgESNrwb+B9VLSFY7nZ0rCdK/Sm1fBqJpI7eLdKQ=="], + + "@yuku-codegen/binding-linux-arm64-musl": ["@yuku-codegen/binding-linux-arm64-musl@0.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6vTw4ZHO9nm4SUkw36uG+UE6/qifZ0E8HIef7Mx/U/c2Zxu3JLBfXtU7U/NN2GMkDmcJkgwjXfpQoYw4Ch5Y1w=="], + + "@yuku-codegen/binding-linux-x64-gnu": ["@yuku-codegen/binding-linux-x64-gnu@0.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+vuC3V3Lw+DB4oJgHV9pVDfQZlsZJnxmbdods7HzxgEALU3P5+czwdAcw3wfh7Ebabt0Ny8eLUb1k9RV5OB/+w=="], + + "@yuku-codegen/binding-linux-x64-musl": ["@yuku-codegen/binding-linux-x64-musl@0.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-QH60PE4eZecmgNGa1/T1cKPhrfxt6ANtu4lrQ1FZ50F9b0GS9WjGmIjrfdSUeQa+f2Iqk3oEFSJdgVHBg1KPNg=="], + + "@yuku-codegen/binding-win32-arm64": ["@yuku-codegen/binding-win32-arm64@0.8.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-6r68c0nKZPIBRXZIBiD7zjlEukBR+xRxpTOVj4n1Fsjcdh4YbbJfjFfmIzdbo9jXR1xL+dPueDVdsRGOUH5MoQ=="], + + "@yuku-codegen/binding-win32-x64": ["@yuku-codegen/binding-win32-x64@0.8.4", "", { "os": "win32", "cpu": "x64" }, "sha512-i+BW77LPjNqe7Apq50J3OeEaVfga3G+eT2bKjb6bj4yO99fil/jnKb4ZDH4JLvby/Q7hRa6VicL2EZ7iS+ifzA=="], + + "@yuku-parser/binding-android-arm64": ["@yuku-parser/binding-android-arm64@0.8.4", "", { "os": "android", "cpu": "arm64" }, "sha512-+HIMmv08Zrh9ugIAEMnKBMMePOl7CDxrjc8Vui1+GG2TJHM1yI1+3wo1pnXB6Nj2IiHugDkQw8ycUI6SA2EUkQ=="], + + "@yuku-parser/binding-darwin-arm64": ["@yuku-parser/binding-darwin-arm64@0.8.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Elf/B/2m3OsyvxoQnBk8Dtu+9csHkzBNs5Yv9GbHjT3x0kVKNWjFusyZgm41VwxcPDqdpRi8tWxNX7OqXkmf/A=="], + + "@yuku-parser/binding-darwin-x64": ["@yuku-parser/binding-darwin-x64@0.8.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CjZuMoXnL5XUkVpDqh4WDPwpAw8CwmtHHnTerGkS45So/sNuwkXdyIAEqqIZfaLopi5W/V9NApAT2md9XizjsQ=="], + + "@yuku-parser/binding-freebsd-x64": ["@yuku-parser/binding-freebsd-x64@0.8.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ibLKORdz71iI4Vs+fyFgvwQ51P5XcxJIyQLa8cSEWqwptRdo+BTcZHIQEcZnFDPUuvmJ19RRH9CoiJdfhr7pZw=="], + + "@yuku-parser/binding-linux-arm-gnu": ["@yuku-parser/binding-linux-arm-gnu@0.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Fo3r5fYhGDcFnl+KN+L9PgtiQPS4AIE1n1mG1o5jZ11p7g5yZ/1EjLFmSHAUsoXreun8KjTsFjEL7P1Sb93PZQ=="], + + "@yuku-parser/binding-linux-arm-musl": ["@yuku-parser/binding-linux-arm-musl@0.8.4", "", { "os": "linux", "cpu": "arm" }, "sha512-BEB31vUEgXPWf7WkoMPSzzJhpC/wWCBXyysRCCPsw47BJ/OtbQsvJbxX9fFDuSRLy3kbyIV/WbdUTgbQ9COxiw=="], + + "@yuku-parser/binding-linux-arm64-gnu": ["@yuku-parser/binding-linux-arm64-gnu@0.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-xGLCRcHn9xVz7JVNyyKtiNJSf503qtUmih9XVSsghgzOmiKUMnHObs69OMMwXN3788tg1jsl11fNjjQlB8idMA=="], + + "@yuku-parser/binding-linux-arm64-musl": ["@yuku-parser/binding-linux-arm64-musl@0.8.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-3kNRi8NJT2q6FQRVCUFHIQ99+kXdi8cVJEEUi6+xtFqpMgNrZNnbgAj28ILsDC7zmclaU+v47eUCeJoKLSCbww=="], + + "@yuku-parser/binding-linux-x64-gnu": ["@yuku-parser/binding-linux-x64-gnu@0.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-isi62oMy94Z3OXwGs2l2rkqRiRyqLmfHeTRHpA/uWZbsNhnm7IdVvkF7e7wHNKAtd5jwGzOqYSroxKv2fOm7Cw=="], + + "@yuku-parser/binding-linux-x64-musl": ["@yuku-parser/binding-linux-x64-musl@0.8.4", "", { "os": "linux", "cpu": "x64" }, "sha512-9RsEw2xYHqU/pjSRBTOupWN3sF8uz9stjJdARfz6o0llvF+yfrj5QHmTiocGGBeAI80fvKmDtr2RIQClUzAPcA=="], + + "@yuku-parser/binding-win32-arm64": ["@yuku-parser/binding-win32-arm64@0.8.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-VEZHo9rEGOBKR20sA3vCO00aQvwWND5aLu7YxeX+YupMZJh9hd1f17AbClJN37Q2iL1PCHht+wDTOKX6tZYqXg=="], + + "@yuku-parser/binding-win32-x64": ["@yuku-parser/binding-win32-x64@0.8.4", "", { "os": "win32", "cpu": "x64" }, "sha512-PeH3VzN1feGjPtDpVEAqf000fPT+nxtw/696LKp/5Z9RJi/MaXpB636QC+5QtrAPSoEnIkyEe+c+mq2QLZPWBA=="], + + "@yuku-toolchain/types": ["@yuku-toolchain/types@0.8.4", "", {}, "sha512-p7JE8flrj7ijZ/qLjHi4UwKqMarMD6zumbKXhrjp2I2iLJOuTYiQyci2U36VlXcUlNyzsY7E/mLnKCHotbzJVw=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -1405,6 +1520,8 @@ "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -1413,6 +1530,8 @@ "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "app-builder": ["app-builder@workspace:packages/app-builder"], @@ -1505,6 +1624,8 @@ "bytewise-core": ["bytewise-core@1.2.3", "", { "dependencies": { "typewise-core": "^1.2" } }, "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA=="], + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -1523,6 +1644,8 @@ "change-case": ["change-case@5.4.4", "", {}, "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w=="], + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -1537,8 +1660,12 @@ "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], @@ -1725,12 +1852,16 @@ "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + "dts-resolver": ["dts-resolver@3.0.0", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ego-graph": ["ego-graph@workspace:packages/ego-graph"], + "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], @@ -1741,6 +1872,8 @@ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], @@ -1751,6 +1884,8 @@ "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1843,6 +1978,8 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], "getopts": ["getopts@2.3.0", "", {}, "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA=="], @@ -1871,6 +2008,10 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -1895,6 +2036,8 @@ "import-in-the-middle": ["import-in-the-middle@3.3.1", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA=="], + "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -2073,6 +2216,10 @@ "markdown-to-jsx": ["markdown-to-jsx@9.8.2", "", { "peerDependencies": { "react": ">= 16.0.0", "solid-js": ">=1.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-rWUuxKB5NsuJmSfUOuXkQ0O5qk0J/Lr3Lk6dzxKoKQI/jeHYlsVfz3zJdMLAhI46hHoXDYERWhtBOiqtWDZ4LA=="], + "marked": ["marked@9.1.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q=="], + + "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], + "match-sorter": ["match-sorter@8.3.0", "", { "dependencies": { "@babel/runtime": "^7.23.8", "remove-accents": "0.5.0" } }, "sha512-8Py1GbZi5zsclYSFcPAH4H5xfTbeD0bOREA7qP/t8bW4MbOSlPl8sbqHOedEV7O+Bxyvxm6xs/v6BXJGe+JDNA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -2103,6 +2250,8 @@ "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -2111,6 +2260,8 @@ "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="], "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], @@ -2121,6 +2272,8 @@ "nitro": ["nitro@3.0.0", "", { "dependencies": { "consola": "^3.4.2", "cookie-es": "^2.0.0", "crossws": "^0.4.1", "db0": "^0.3.4", "esbuild": "^0.25.10", "fetchdts": "^0.1.7", "h3": "2.0.1-rc.2", "jiti": "^2.6.1", "nf3": "^0.1.1", "ofetch": "^1.4.1", "ohash": "^2.0.11", "rendu": "^0.0.6", "rollup": "^4.52.4", "srvx": "^0.8.15", "undici": "^7.16.0", "unenv": "2.0.0-rc.21", "unstorage": "2.0.0-alpha.3" }, "peerDependencies": { "rolldown": "*", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs", "nitropack": "dist/cli/index.mjs" } }, "sha512-pPrH77/oiYz3q1xGgOYQPEkXERsCi4PkErL2DpHhg4raag3EX7Zh41KFJ0ploSmflmDOPRPeAtC1/19fVrOqoQ=="], + "node-emoji": ["node-emoji@2.2.0", "", { "dependencies": { "@sindresorhus/is": "^4.6.0", "char-regex": "^1.0.2", "emojilib": "^2.4.0", "skin-tone": "^2.0.0" } }, "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -2177,8 +2330,12 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -2277,6 +2434,8 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "publint": ["publint@0.3.23", "", { "dependencies": { "@publint/pack": "^0.1.6", "package-manager-detector": "^1.7.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "./src/cli.js" } }, "sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -2285,6 +2444,8 @@ "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -2369,6 +2530,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -2379,6 +2542,10 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="], + + "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.14", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.8.0", "yuku-codegen": "^0.8.0", "yuku-parser": "^0.8.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -2389,6 +2556,8 @@ "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], @@ -2437,6 +2606,8 @@ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -2499,6 +2670,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "svg-sprite": ["svg-sprite@2.0.4", "", { "dependencies": { "@resvg/resvg-js": "^2.6.0", "@xmldom/xmldom": "^0.8.10", "async": "^3.2.5", "css-selector-parser": "^1.4.1", "csso": "^4.2.0", "cssom": "^0.5.0", "glob": "^7.2.3", "js-yaml": "^4.1.0", "lodash.escape": "^4.0.1", "lodash.merge": "^4.6.2", "mustache": "^4.2.0", "prettysize": "^2.0.0", "svgo": "^2.8.0", "vinyl": "^2.2.1", "winston": "^3.11.0", "xpath": "^0.0.34", "yargs": "^17.7.2" }, "bin": { "svg-sprite": "bin/svg-sprite.js" } }, "sha512-kjDoATgr4k6tdtfQczpkbuFW6RE7tPUPe/rbRd1n2NV92kdwaXEZMIxJqAZfMGOMfU/Kp1u89SUYsfHCbAvVHg=="], @@ -2545,6 +2718,10 @@ "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], "tildify": ["tildify@2.0.0", "", {}, "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw=="], @@ -2579,6 +2756,8 @@ "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], @@ -2589,6 +2768,8 @@ "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], @@ -2613,6 +2794,8 @@ "ui-icons": ["ui-icons@workspace:packages/ui-icons"], + "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], @@ -2621,6 +2804,8 @@ "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], + "union-value": ["union-value@1.0.1", "", { "dependencies": { "arr-union": "^3.1.0", "get-value": "^2.0.6", "is-extendable": "^0.1.1", "set-value": "^2.0.1" } }, "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg=="], "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], @@ -2641,6 +2826,10 @@ "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], + + "verkit": ["verkit@0.3.2", "", {}, "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg=="], + "vinyl": ["vinyl@2.2.1", "", { "dependencies": { "clone": "^2.1.1", "clone-buffer": "^1.0.0", "clone-stats": "^1.0.0", "cloneable-readable": "^1.0.0", "remove-trailing-separator": "^1.0.1", "replace-ext": "^1.0.0" } }, "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw=="], "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], @@ -2711,6 +2900,12 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yuku-ast": ["yuku-ast@0.8.4", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.4" } }, "sha512-s7EWfWIQkaGmsGnyr/BU0jli9YTN5TvrKIsSmALyRD9elumDQInuhv0BrVObENKVCxr9W3Ikmnx5u02KvfuUmw=="], + + "yuku-codegen": ["yuku-codegen@0.8.4", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.4" }, "optionalDependencies": { "@yuku-codegen/binding-android-arm64": "0.8.4", "@yuku-codegen/binding-darwin-arm64": "0.8.4", "@yuku-codegen/binding-darwin-x64": "0.8.4", "@yuku-codegen/binding-freebsd-x64": "0.8.4", "@yuku-codegen/binding-linux-arm-gnu": "0.8.4", "@yuku-codegen/binding-linux-arm-musl": "0.8.4", "@yuku-codegen/binding-linux-arm64-gnu": "0.8.4", "@yuku-codegen/binding-linux-arm64-musl": "0.8.4", "@yuku-codegen/binding-linux-x64-gnu": "0.8.4", "@yuku-codegen/binding-linux-x64-musl": "0.8.4", "@yuku-codegen/binding-win32-arm64": "0.8.4", "@yuku-codegen/binding-win32-x64": "0.8.4" } }, "sha512-1Rw+NYcmB1xkHAWlsIpbwIv/Fr50idtEbLf7OjDA+90dny6PM4Krz7Fs0TT+w2PBdjaldZpN4ye5wR4Dhlm8vA=="], + + "yuku-parser": ["yuku-parser@0.8.4", "", { "dependencies": { "@yuku-toolchain/types": "^0.8.4", "yuku-ast": "^0.8.4" }, "optionalDependencies": { "@yuku-parser/binding-android-arm64": "0.8.4", "@yuku-parser/binding-darwin-arm64": "0.8.4", "@yuku-parser/binding-darwin-x64": "0.8.4", "@yuku-parser/binding-freebsd-x64": "0.8.4", "@yuku-parser/binding-linux-arm-gnu": "0.8.4", "@yuku-parser/binding-linux-arm-musl": "0.8.4", "@yuku-parser/binding-linux-arm64-gnu": "0.8.4", "@yuku-parser/binding-linux-arm64-musl": "0.8.4", "@yuku-parser/binding-linux-x64-gnu": "0.8.4", "@yuku-parser/binding-linux-x64-musl": "0.8.4", "@yuku-parser/binding-win32-arm64": "0.8.4", "@yuku-parser/binding-win32-x64": "0.8.4" } }, "sha512-sw41wouvT5rUmLIp87hmvm5vtF+MRSI3x6yjq6xqpYmtkQj+Ht6N7xRQ8lMhLv8N7JAzughGj0Rfi0jQRSu9HQ=="], + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -2727,6 +2922,12 @@ "@apm-js-collab/code-transformer/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "@arethetypeswrong/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "@arethetypeswrong/core/cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + + "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -2811,6 +3012,14 @@ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "cli-highlight/parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + + "cli-highlight/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], + + "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2835,6 +3044,8 @@ "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "ego-graph/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -2849,12 +3060,16 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "logform/@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "maplibre-gl/@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@24.10.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw=="], "marble-api/tailwind-preset": ["tailwind-preset@1.0.8", "", { "dependencies": { "@tailwindcss/aspect-ratio": "0.4.0", "@tailwindcss/forms": "0.5.2", "@tailwindcss/typography": "0.5.2", "tailwindcss": "3.1.3" } }, "sha512-QCkSnHzCviuz4mclWBYZFoV6Dl+vyh9DjAqpE0yJao8RkRrrp7oZvZl6aBkMCuHI7joIi7PQuSsjDvpxUlUtbg=="], + "marked-terminal/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "match-sorter/remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -2865,6 +3080,10 @@ "oazapfts/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "pg/pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], @@ -2885,6 +3104,10 @@ "restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "rolldown-plugin-dts/obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], "ssh-remote-port-forward/@types/ssh2": ["@types/ssh2@0.5.52", "", { "dependencies": { "@types/node": "*", "@types/ssh2-streams": "*" } }, "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg=="], @@ -2907,6 +3130,10 @@ "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "tsdown/obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + + "winston/@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + "winston/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "winston-transport/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -2941,6 +3168,8 @@ "@apidevtools/swagger-cli/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "@arethetypeswrong/cli/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/template/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -2963,6 +3192,18 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + + "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cli-table3/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cloneable-readable/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -3079,6 +3320,12 @@ "@apidevtools/swagger-cli/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cli-highlight/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "dockerode/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], diff --git a/packages/app-builder/package.json b/packages/app-builder/package.json index db8ec27c3..2ff59a969 100644 --- a/packages/app-builder/package.json +++ b/packages/app-builder/package.json @@ -92,6 +92,7 @@ "decode-formdata": "^0.9.0", "deepsignal": "^1.6.0", "dinero.js": "2.0.2", + "ego-graph": "workspace:*", "embla-carousel-react": "^8.6.0", "firebase": "^12.13.0", "i18next": "^26.3.0", diff --git a/packages/app-builder/src/components/Graph/GraphImpl.tsx b/packages/app-builder/src/components/Graph/GraphImpl.tsx index b9cd9c25f..f998d03f7 100644 --- a/packages/app-builder/src/components/Graph/GraphImpl.tsx +++ b/packages/app-builder/src/components/Graph/GraphImpl.tsx @@ -10,6 +10,7 @@ import { type NodeMouseHandler, ReactFlow, } from '@xyflow/react'; +import { reachableNodeIds } from 'ego-graph'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Icon } from 'ui-icons'; import { type GraphData } from '../../routes/_app/_builder/test-graph/-data'; @@ -19,21 +20,15 @@ import { PIVOT_TYPE_ATTRIBUTES, useCustomerGraph, } from './CustomerGraphContext'; -import { clusterGraphElements } from './cluster-graph'; import { createGraphTypeHelpers } from './data-model-map'; import { graphEdgeTypes, graphNodeTypes } from './GraphComponents'; -import { withBestHandles } from './graph-handles'; import { type GraphObjectRef, nodeKey, parseNodeKey } from './graph-keys'; +import { clusterGraphElements, type GraphLayoutMode, layoutByMode, withBestHandles } from './graph-layout'; import { type GraphRfEdge, type GraphRfNode } from './graph-rf-types'; -import { reachableNodeIds } from './graph-traversal'; -import { layoutGraphElements as layoutGraphElementsRadDagre } from './layout-graph'; -import { layoutGraphElements as layoutGraphElementsBalanced } from './layout-graph-2'; -import { layoutGraphElements as layoutGraphElementsRadial } from './layout-graph-3'; import { toFlatFlowElements } from './utils'; import '@xyflow/react/dist/style.css'; -import { match } from 'ts-pattern'; -export type GraphLayoutMode = 'rad-dagre' | 'balanced' | 'radial'; +export type { GraphLayoutMode }; /** Re-run layout once React Flow has measured node sizes (must be a ReactFlow child). */ function GraphMeasuredLayout({ @@ -62,19 +57,6 @@ function resolveStartKey(nodes: GraphRfNode[], fallback: string): string { return start?.id ?? fallback; } -function layoutByMode( - mode: GraphLayoutMode, - nodes: GraphRfNode[], - edges: GraphRfEdge[], - startKey: string, -): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { - return match(mode) - .with('balanced', () => layoutGraphElementsBalanced(nodes, edges, startKey)) - .with('rad-dagre', () => layoutGraphElementsRadDagre(nodes, edges, startKey)) - .with('radial', () => layoutGraphElementsRadial(nodes, edges, startKey)) - .exhaustive(); -} - type VisibilityFilters = Pick< CustomerGraphContextValue, 'showPersons' | 'showCompanies' | 'attributes' | 'hiddenNodeIds' diff --git a/packages/app-builder/src/components/Graph/cluster-graph.spec.ts b/packages/app-builder/src/components/Graph/cluster-graph.spec.ts deleted file mode 100644 index 3554f8876..000000000 --- a/packages/app-builder/src/components/Graph/cluster-graph.spec.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { clusterGraphElements } from './cluster-graph'; -import { clusterNodeId } from './graph-keys'; -import { type ClusterRfNode, type GraphRfEdge, type GraphRfNode } from './graph-rf-types'; - -function personNode(id: string, isStart = false): GraphRfNode { - return { - id, - position: { x: 0, y: 0 }, - type: 'person', - data: { - label: id, - subEntity: 'natural', - isStart, - objectType: 'users', - objectId: id, - }, - }; -} - -function pivotNode(id: string, rawType = 'same_ip'): GraphRfNode { - return { - id, - position: { x: 0, y: 0 }, - type: 'pivot', - data: { label: id, rawType }, - }; -} - -function linkEdge(source: string, target: string, label = 'link'): GraphRfEdge { - return { - id: `${source}->${target}:${label}`, - source, - target, - type: 'link', - label, - data: { kind: 'link' }, - }; -} - -function matchEdge(source: string, target: string, label = 'same_ip'): GraphRfEdge { - return { - id: `${source}->${target}:${label}`, - source, - target, - type: 'match', - animated: true, - label, - data: { kind: 'match' }, - }; -} - -/** `start -- root -- c0..c{n-1}`, a fan of `n` leaves under a single root. */ -function fanFixture(n: number): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { - const nodes: GraphRfNode[] = [personNode('start', true), personNode('root')]; - const edges: GraphRfEdge[] = [linkEdge('start', 'root')]; - for (let i = 0; i < n; i++) { - nodes.push(personNode(`c${i}`)); - edges.push(linkEdge('root', `c${i}`)); - } - return { nodes, edges }; -} - -function options(threshold: number, expanded: string[] = []) { - return { startKey: 'start', threshold, expandedRootIds: new Set(expanded) }; -} - -function clustersOf(nodes: GraphRfNode[]): ClusterRfNode[] { - return nodes.filter((n): n is ClusterRfNode => n.type === 'cluster'); -} - -describe('clusterGraphElements', () => { - it('is identity when the threshold is 0', () => { - const { nodes, edges } = fanFixture(20); - const result = clusterGraphElements(nodes, edges, options(0)); - - expect(result.nodes).toBe(nodes); - expect(result.edges).toBe(edges); - }); - - it('leaves a subtree below the threshold untouched', () => { - const { nodes, edges } = fanFixture(3); - const result = clusterGraphElements(nodes, edges, options(10)); - - expect(clustersOf(result.nodes)).toHaveLength(0); - expect(result.nodes).toHaveLength(5); - }); - - it('leaves a subtree exactly on the threshold untouched', () => { - // root + 9 children = 10 foldable nodes; a threshold of 10 is not exceeded. - const { nodes, edges } = fanFixture(9); - const result = clusterGraphElements(nodes, edges, options(10)); - - expect(clustersOf(result.nodes)).toHaveLength(0); - }); - - it('collapses a subtree above the threshold into one chip', () => { - // root + 9 children = 10 foldable nodes, one over the threshold of 9. - const { nodes, edges } = fanFixture(9); - const result = clusterGraphElements(nodes, edges, options(9)); - - const clusters = clustersOf(result.nodes); - expect(clusters).toHaveLength(1); - expect(clusters[0]!.id).toBe(clusterNodeId('root')); - expect(clusters[0]!.data.nodeCount).toBe(10); - // 9 root->child edges are internal; start->root becomes the merged edge. - expect(clusters[0]!.data.internalEdgeCount).toBe(9); - // The chip carries its entry point so it can render it like a person node. - expect(clusters[0]!.data.root.objectId).toBe('root'); - - expect(result.nodes.map((n) => n.id)).toEqual(['start', clusterNodeId('root')]); - expect(result.edges).toHaveLength(1); - expect(result.edges[0]!.data?.mergedCount).toBe(1); - }); - - it('never clusters the start node', () => { - const { nodes, edges } = fanFixture(20); - const result = clusterGraphElements(nodes, edges, options(2)); - - expect(result.nodes.some((n) => n.id === 'start')).toBe(true); - expect(clustersOf(result.nodes)).toHaveLength(1); - }); - - it('suppresses the chip for an expanded root and re-applies the rule one level down', () => { - // start -- root -- a -- a0..a4 - // '- b -- b0..b4 - const nodes: GraphRfNode[] = [personNode('start', true), personNode('root'), personNode('a'), personNode('b')]; - const edges: GraphRfEdge[] = [linkEdge('start', 'root'), linkEdge('root', 'a'), linkEdge('root', 'b')]; - for (const parent of ['a', 'b']) { - for (let i = 0; i < 5; i++) { - nodes.push(personNode(`${parent}${i}`)); - edges.push(linkEdge(parent, `${parent}${i}`)); - } - } - - // Threshold 5: root (13 foldable) and each of a / b (6) are over it. - const collapsed = clusterGraphElements(nodes, edges, options(5)); - expect(clustersOf(collapsed.nodes).map((c) => c.id)).toEqual([clusterNodeId('root')]); - - const expanded = clusterGraphElements(nodes, edges, options(5, ['root'])); - const nested = clustersOf(expanded.nodes); - expect(nested.map((c) => c.id).sort()).toEqual([clusterNodeId('a'), clusterNodeId('b')]); - expect(nested.every((c) => c.data.nodeCount === 6)).toBe(true); - // root surfaced, its two children are chips. - const rootNode = expanded.nodes.find((n) => n.id === 'root'); - expect(rootNode).toBeDefined(); - // ...and it is flagged so it can be regrouped. - expect(rootNode!.type === 'person' && rootNode!.data.isExpandedClusterRoot).toBe(true); - }); - - it('does not flag a branch root that is too small to regroup', () => { - const { nodes, edges } = fanFixture(3); - const result = clusterGraphElements(nodes, edges, options(10, ['root'])); - - const rootNode = result.nodes.find((n) => n.id === 'root')!; - expect(rootNode.type === 'person' && rootNode.data.isExpandedClusterRoot).toBeFalsy(); - }); - - it('keeps a pivot inside a collapsed branch on canvas and merges its member edges', () => { - const { nodes, edges } = fanFixture(9); - nodes.push(pivotNode('same_ip:1.2.3.4')); - // The pivot matches three members of the branch. - edges.push(matchEdge('same_ip:1.2.3.4', 'c0')); - edges.push(matchEdge('same_ip:1.2.3.4', 'c1')); - edges.push(matchEdge('same_ip:1.2.3.4', 'c2')); - - const result = clusterGraphElements(nodes, edges, options(9)); - - expect(result.nodes.some((n) => n.id === 'same_ip:1.2.3.4')).toBe(true); - expect(clustersOf(result.nodes)).toHaveLength(1); - - const pivotEdges = result.edges.filter((e) => e.source === 'same_ip:1.2.3.4' || e.target === 'same_ip:1.2.3.4'); - expect(pivotEdges).toHaveLength(1); - expect(pivotEdges[0]!.type).toBe('match'); - expect(pivotEdges[0]!.data?.mergedCount).toBe(3); - }); - - it('merges many member links to the same external node into one edge', () => { - const { nodes, edges } = fanFixture(9); - nodes.push(personNode('outsider')); - // outsider is reached from start directly, so it stays visible... - edges.push(linkEdge('start', 'outsider')); - // ...and five members link across to it. - for (let i = 0; i < 5; i++) { - edges.push(linkEdge(`c${i}`, 'outsider')); - } - - const result = clusterGraphElements(nodes, edges, options(9)); - const crossEdges = result.edges.filter((e) => e.source === 'outsider' || e.target === 'outsider'); - - // start->outsider passes through; the five member links merge into one. - expect(crossEdges).toHaveLength(2); - const merged = crossEdges.find((e) => e.data?.mergedCount != null)!; - expect(merged.data?.mergedCount).toBe(5); - expect(merged.type).toBe('link'); - }); - - it('merges links between two collapsed branches into a single edge', () => { - const nodes: GraphRfNode[] = [personNode('start', true), personNode('a'), personNode('b')]; - const edges: GraphRfEdge[] = [linkEdge('start', 'a'), linkEdge('start', 'b')]; - for (const parent of ['a', 'b']) { - for (let i = 0; i < 4; i++) { - nodes.push(personNode(`${parent}${i}`)); - edges.push(linkEdge(parent, `${parent}${i}`)); - } - } - // Three cross-branch links. - edges.push(linkEdge('a0', 'b0'), linkEdge('a1', 'b1'), linkEdge('a2', 'b2')); - - // Each branch is 5 foldable nodes, one over the threshold of 4. - const result = clusterGraphElements(nodes, edges, options(4)); - const chipA = clusterNodeId('a'); - const chipB = clusterNodeId('b'); - - expect( - clustersOf(result.nodes) - .map((c) => c.id) - .sort(), - ).toEqual([chipA, chipB].sort()); - - const between = result.edges.filter( - (e) => (e.source === chipA && e.target === chipB) || (e.source === chipB && e.target === chipA), - ); - expect(between).toHaveLength(1); - // Six distinct member nodes are involved across the three merged links. - expect(between[0]!.data?.mergedCount).toBe(6); - }); - - it('clusters a match-only island owned by a pivot, reachable only via the pivot', () => { - const nodes: GraphRfNode[] = [personNode('start', true), personNode('neighbor'), pivotNode('same_ip:9.9.9.9')]; - const edges: GraphRfEdge[] = [linkEdge('start', 'neighbor'), matchEdge('same_ip:9.9.9.9', 'neighbor')]; - // A chain of match-only nodes hanging off the pivot. - let prev = 'same_ip:9.9.9.9'; - for (let i = 0; i < 6; i++) { - nodes.push(personNode(`island${i}`)); - edges.push(matchEdge(prev, `island${i}`)); - prev = `island${i}`; - } - - // Drilled into `neighbor`, so the walk descends through the pinned pivot - // and the island becomes the shallowest qualifying subtree. - const result = clusterGraphElements(nodes, edges, options(5, ['neighbor'])); - - expect(result.nodes.some((n) => n.id === 'same_ip:9.9.9.9')).toBe(true); - const clusters = clustersOf(result.nodes); - expect(clusters).toHaveLength(1); - expect(clusters[0]!.id).toBe(clusterNodeId('island0')); - expect(clusters[0]!.data.nodeCount).toBe(6); - - const chipEdges = result.edges.filter((e) => e.source === clusters[0]!.id || e.target === clusters[0]!.id); - expect(chipEdges).toHaveLength(1); - expect(chipEdges[0]!.source === 'same_ip:9.9.9.9' || chipEdges[0]!.target === 'same_ip:9.9.9.9').toBe(true); - }); - - it('does not count pinned pivots toward a chip nodeCount', () => { - // start -- root -- c0..c8, with a pivot hanging off c0. - const { nodes, edges } = fanFixture(9); - nodes.push(pivotNode('same_iban:DE00')); - edges.push(matchEdge('same_iban:DE00', 'c0')); - - const result = clusterGraphElements(nodes, edges, options(9)); - const cluster = clustersOf(result.nodes)[0]!; - - expect(cluster.data.nodeCount).toBe(10); - expect(cluster.data.memberIds).not.toContain('same_iban:DE00'); - }); -}); diff --git a/packages/app-builder/src/components/Graph/cluster-graph.ts b/packages/app-builder/src/components/Graph/cluster-graph.ts deleted file mode 100644 index bad6b3051..000000000 --- a/packages/app-builder/src/components/Graph/cluster-graph.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { clusterNodeId } from './graph-keys'; -import { type ClusterRfNode, type GraphRfEdge, type GraphRfNode, isMatchEdge } from './graph-rf-types'; -import { bfsSpanningTreeEdges, buildChildrenMap, collectSubtreeIds } from './graph-traversal'; - -export type ClusterOptions = { - startKey: string; - /** A branch collapses once its foldable size *exceeds* this. `0` disables clustering. */ - threshold: number; - /** Branch roots the user drilled into; their chips are suppressed. */ - expandedRootIds: Set; -}; - -export type ClusterResult = { - nodes: GraphRfNode[]; - edges: GraphRfEdge[]; -}; - -/** Pivots and the start node are never folded into a chip. */ -function isPinned(node: GraphRfNode, startKey: string): boolean { - if (node.type === 'pivot') return true; - return node.id === startKey; -} - -/** - * Foldable nodes in each subtree, keyed by root. Pinned nodes count 0, so a - * chip never advertises pivots it cannot swallow. - */ -function buildSubtreeWeights( - children: Map, - rootId: string, - pinnedIds: Set, - weights: Map, -): number { - let weight = pinnedIds.has(rootId) ? 0 : 1; - for (const child of children.get(rootId) ?? []) { - weight += buildSubtreeWeights(children, child, pinnedIds, weights); - } - weights.set(rootId, weight); - return weight; -} - -/** Unordered pair key so a (chip, external) pair can only ever yield one edge. */ -function pairKey(a: string, b: string): string { - return a < b ? `${a}${b}` : `${b}${a}`; -} - -/** - * Fold branches of the start-rooted BFS tree into single chip nodes. - * - * Walks down from the start and collapses the *shallowest* subtree whose - * foldable size exceeds `threshold`, so expanding a chip re-applies the rule to - * its children — clusters nest. Pivots stay on canvas even when they sit inside - * a collapsed branch; their edges to members merge into the chip. - */ -export function clusterGraphElements( - nodes: GraphRfNode[], - edges: GraphRfEdge[], - options: ClusterOptions, -): ClusterResult { - if (options.threshold <= 0 || nodes.length === 0) { - return { nodes, edges }; - } - - const nodesById = new Map(nodes.map((n) => [n.id, n])); - const nodeIds = nodes.map((n) => n.id); - const resolvedStart = nodesById.has(options.startKey) ? options.startKey : (nodeIds[0] ?? ''); - if (!resolvedStart) return { nodes, edges }; - - const pinnedIds = new Set(nodes.filter((n) => isPinned(n, resolvedStart)).map((n) => n.id)); - - // BFS over *all* edges: pivot-owned match-only islands are in the tree too. - const treeEdges = bfsSpanningTreeEdges(nodeIds, edges, resolvedStart); - const children = buildChildrenMap(treeEdges); - - const weights = new Map(); - buildSubtreeWeights(children, resolvedStart, pinnedIds, weights); - - // Walk down; collapse the first child that qualifies, otherwise recurse. - const clusterNodes: ClusterRfNode[] = []; - const memberOf = new Map(); - /** Roots that would have collapsed but for `expandedRootIds` — they can regroup. */ - const expandedRoots = new Set(); - const queue = [resolvedStart]; - while (queue.length > 0) { - const cur = queue.shift()!; - for (const child of children.get(cur) ?? []) { - const weight = weights.get(child) ?? 0; - const rootNode = nodesById.get(child); - // A chip renders its root like a person node, so only persons can head one. - // Strictly greater: a threshold of N collapses branches *larger* than N. - const qualifies = weight > options.threshold && !pinnedIds.has(child) && rootNode?.type === 'person'; - - if (!qualifies || options.expandedRootIds.has(child)) { - if (qualifies) expandedRoots.add(child); - queue.push(child); - continue; - } - - // Pinned descendants stay on canvas, but everything below them is still - // part of this subtree — do not re-explore from them or their children - // would be claimed twice. - const memberIds = collectSubtreeIds(children, child).filter((id) => !pinnedIds.has(id)); - if (memberIds.length === 0) { - queue.push(child); - continue; - } - - const clusterId = clusterNodeId(child); - clusterNodes.push({ - id: clusterId, - position: { x: 0, y: 0 }, - // Explicit size: the first layout pass runs before React Flow measures. - // Matches the person node's tag-card footprint. - width: 192, - height: 72, - type: 'cluster', - data: { - root: rootNode.data, - nodeCount: memberIds.length, - internalEdgeCount: 0, - memberIds, - }, - }); - for (const id of memberIds) { - memberOf.set(id, clusterId); - } - } - } - - if (clusterNodes.length === 0 && expandedRoots.size === 0) { - return { nodes, edges }; - } - - // Rewrite edges: internal ones vanish into a count, external ones merge per - // endpoint pair (never per pair+kind, so a pair yields exactly one edge). - const internalEdgeCount = new Map(); - type Merged = { source: string; target: string; memberIds: Set; allMatch: boolean }; - const mergedByPair = new Map(); - const passthrough: GraphRfEdge[] = []; - - for (const edge of edges) { - const sourceCluster = memberOf.get(edge.source); - const targetCluster = memberOf.get(edge.target); - - if (sourceCluster != null && sourceCluster === targetCluster) { - internalEdgeCount.set(sourceCluster, (internalEdgeCount.get(sourceCluster) ?? 0) + 1); - continue; - } - - if (sourceCluster == null && targetCluster == null) { - passthrough.push(edge); - continue; - } - - const source = sourceCluster ?? edge.source; - const target = targetCluster ?? edge.target; - const key = pairKey(source, target); - const existing = mergedByPair.get(key); - const isMatch = isMatchEdge(edge); - - if (existing) { - if (sourceCluster != null) existing.memberIds.add(edge.source); - if (targetCluster != null) existing.memberIds.add(edge.target); - existing.allMatch = existing.allMatch && isMatch; - continue; - } - - const memberIds = new Set(); - if (sourceCluster != null) memberIds.add(edge.source); - if (targetCluster != null) memberIds.add(edge.target); - mergedByPair.set(key, { source, target, memberIds, allMatch: isMatch }); - } - - for (const cluster of clusterNodes) { - cluster.data.internalEdgeCount = internalEdgeCount.get(cluster.id) ?? 0; - } - - const mergedEdges: GraphRfEdge[] = [...mergedByPair.values()].map(({ source, target, memberIds, allMatch }) => ({ - id: `agg:${source}->${target}`, - source, - target, - type: allMatch ? ('match' as const) : ('link' as const), - animated: true, - data: { kind: allMatch ? 'match' : 'link', mergedCount: memberIds.size }, - })); - - const survivingNodes = nodes - .filter((n) => !memberOf.has(n.id)) - .map((n) => - n.type === 'person' && expandedRoots.has(n.id) ? { ...n, data: { ...n.data, isExpandedClusterRoot: true } } : n, - ); - - return { - nodes: [...survivingNodes, ...clusterNodes], - edges: [...passthrough, ...mergedEdges], - }; -} diff --git a/packages/app-builder/src/components/Graph/graph-handles.ts b/packages/app-builder/src/components/Graph/graph-handles.ts deleted file mode 100644 index 37a3dbcac..000000000 --- a/packages/app-builder/src/components/Graph/graph-handles.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type GraphRfEdge, type GraphRfNode } from './graph-rf-types'; - -/** Fallbacks for the first layout pass, before React Flow has measured anything. */ -export const DEFAULT_NODE_WIDTH = 180; -export const DEFAULT_NODE_HEIGHT = 56; - -export type Point = { x: number; y: number }; - -export function nodeMeasuredSize(node: GraphRfNode): { width: number; height: number } { - return { - width: node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH, - height: node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT, - }; -} - -export function nodeCenter(node: GraphRfNode, position: Point = node.position): Point { - const { width, height } = nodeMeasuredSize(node); - return { x: position.x + width / 2, y: position.y + height / 2 }; -} - -export function topLeftFromCenter(center: Point, width: number, height: number): Point { - return { x: center.x - width / 2, y: center.y - height / 2 }; -} - -type HandleSide = 't' | 'r' | 'b' | 'l'; - -/** Dominant axis from source → target (avoids e.g. Top→Bottom when nodes are side-by-side). */ -function sideFromDelta(dx: number, dy: number): HandleSide { - if (Math.abs(dx) >= Math.abs(dy)) { - return dx >= 0 ? 'r' : 'l'; - } - return dy >= 0 ? 'b' : 't'; -} - -/** Target handles are bare sides, source handles are `s`-prefixed — see `FourHandles`. */ -function handlesForEdge(source: GraphRfNode, target: GraphRfNode) { - const from = nodeCenter(source); - const to = nodeCenter(target); - return { - sourceHandle: `s${sideFromDelta(to.x - from.x, to.y - from.y)}`, - targetHandle: sideFromDelta(from.x - to.x, from.y - to.y), - }; -} - -/** Re-point every edge at the handles facing its counterpart. Unchanged edges keep identity. */ -export function withBestHandles(nodes: GraphRfNode[], edges: GraphRfEdge[]): GraphRfEdge[] { - const byId = new Map(nodes.map((n) => [n.id, n])); - return edges.map((edge) => { - const source = byId.get(edge.source); - const target = byId.get(edge.target); - if (!source || !target) return edge; - const { sourceHandle, targetHandle } = handlesForEdge(source, target); - if (edge.sourceHandle === sourceHandle && edge.targetHandle === targetHandle) return edge; - return { ...edge, sourceHandle, targetHandle }; - }); -} diff --git a/packages/app-builder/src/components/Graph/graph-layout.spec.ts b/packages/app-builder/src/components/Graph/graph-layout.spec.ts new file mode 100644 index 000000000..30bf188e6 --- /dev/null +++ b/packages/app-builder/src/components/Graph/graph-layout.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { clusterNodeId } from './graph-keys'; +import { clusterGraphElements, layoutByMode } from './graph-layout'; +import { type ClusterRfNode, type GraphRfEdge, type GraphRfNode } from './graph-rf-types'; + +/** + * Covers Marble's half of the `ego-graph` contract: turning a fold plan into + * chips, and applying positions and handles. The planning and geometry + * themselves are tested in `packages/ego-graph`. + */ + +function personNode(id: string, isStart = false): GraphRfNode { + return { + id, + position: { x: 0, y: 0 }, + type: 'person', + data: { label: id, subEntity: 'natural', isStart, objectType: 'users', objectId: id }, + }; +} + +function pivotNode(id: string): GraphRfNode { + return { id, position: { x: 0, y: 0 }, type: 'pivot', data: { label: id, rawType: 'same_ip' } }; +} + +function linkEdge(source: string, target: string): GraphRfEdge { + return { id: `${source}->${target}`, source, target, type: 'link', data: { kind: 'link' } }; +} + +function matchEdge(source: string, target: string): GraphRfEdge { + return { id: `${source}->${target}`, source, target, type: 'match', data: { kind: 'match' } }; +} + +/** `start -- root -- c0..c{n-1}` */ +function fanFixture(n: number): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { + const nodes: GraphRfNode[] = [personNode('start', true), personNode('root')]; + const edges: GraphRfEdge[] = [linkEdge('start', 'root')]; + for (let i = 0; i < n; i++) { + nodes.push(personNode(`c${i}`)); + edges.push(linkEdge('root', `c${i}`)); + } + return { nodes, edges }; +} + +function options(threshold: number, expanded: string[] = []) { + return { startKey: 'start', threshold, expandedRootIds: new Set(expanded) }; +} + +function chipsOf(nodes: GraphRfNode[]): ClusterRfNode[] { + return nodes.filter((n): n is ClusterRfNode => n.type === 'cluster'); +} + +describe('clusterGraphElements', () => { + it('is identity when the threshold is 0', () => { + const { nodes, edges } = fanFixture(20); + const result = clusterGraphElements(nodes, edges, options(0)); + + expect(result.nodes).toBe(nodes); + expect(result.edges).toBe(edges); + }); + + it('builds one chip carrying its branch root, counts and members', () => { + const { nodes, edges } = fanFixture(9); + const result = clusterGraphElements(nodes, edges, options(9)); + + const chips = chipsOf(result.nodes); + expect(chips).toHaveLength(1); + expect(chips[0]!.id).toBe(clusterNodeId('root')); + expect(chips[0]!.data.nodeCount).toBe(10); + expect(chips[0]!.data.internalEdgeCount).toBe(9); + // The chip renders its entry point like a person card. + expect(chips[0]!.data.root.objectId).toBe('root'); + // Explicit footprint: layout runs before React Flow measures. + expect({ width: chips[0]!.width, height: chips[0]!.height }).toEqual({ width: 192, height: 72 }); + + expect(result.nodes.map((n) => n.id)).toEqual(['start', clusterNodeId('root')]); + expect(result.edges).toHaveLength(1); + expect(result.edges[0]!.data?.mergedCount).toBe(1); + }); + + it('keeps a pivot on canvas and merges its member edges into one match edge', () => { + const { nodes, edges } = fanFixture(9); + nodes.push(pivotNode('same_ip:1.2.3.4')); + for (const member of ['c0', 'c1', 'c2']) { + edges.push(matchEdge('same_ip:1.2.3.4', member)); + } + + const result = clusterGraphElements(nodes, edges, options(9)); + expect(result.nodes.some((n) => n.id === 'same_ip:1.2.3.4')).toBe(true); + + const pivotEdges = result.edges.filter((e) => e.source === 'same_ip:1.2.3.4' || e.target === 'same_ip:1.2.3.4'); + expect(pivotEdges).toHaveLength(1); + expect(pivotEdges[0]!.type).toBe('match'); + expect(pivotEdges[0]!.data?.mergedCount).toBe(3); + }); + + it('passes through edges with no folded endpoint and merges the rest', () => { + const { nodes, edges } = fanFixture(9); + nodes.push(personNode('outsider')); + edges.push(linkEdge('start', 'outsider')); + for (let i = 0; i < 5; i++) { + edges.push(linkEdge(`c${i}`, 'outsider')); + } + + const crossing = clusterGraphElements(nodes, edges, options(9)).edges.filter( + (e) => e.source === 'outsider' || e.target === 'outsider', + ); + + // start->outsider survives untouched; the five member links become one. + expect(crossing).toHaveLength(2); + const merged = crossing.find((e) => e.data?.mergedCount != null)!; + expect(merged.data?.mergedCount).toBe(5); + expect(merged.type).toBe('link'); + }); + + it('surfaces an expanded root, flags it for regrouping, and chips its children', () => { + const nodes: GraphRfNode[] = [personNode('start', true), personNode('root'), personNode('a'), personNode('b')]; + const edges: GraphRfEdge[] = [linkEdge('start', 'root'), linkEdge('root', 'a'), linkEdge('root', 'b')]; + for (const parent of ['a', 'b']) { + for (let i = 0; i < 5; i++) { + nodes.push(personNode(`${parent}${i}`)); + edges.push(linkEdge(parent, `${parent}${i}`)); + } + } + + expect(chipsOf(clusterGraphElements(nodes, edges, options(5)).nodes).map((c) => c.id)).toEqual([ + clusterNodeId('root'), + ]); + + const expanded = clusterGraphElements(nodes, edges, options(5, ['root'])); + expect( + chipsOf(expanded.nodes) + .map((c) => c.id) + .sort(), + ).toEqual([clusterNodeId('a'), clusterNodeId('b')]); + + const rootNode = expanded.nodes.find((n) => n.id === 'root'); + expect(rootNode?.type === 'person' && rootNode.data.isExpandedClusterRoot).toBe(true); + }); + + it('will not fold an island reachable only through a pivot', () => { + // One spanning tree: a pivot cannot hold a branch together. See + // packages/ego-graph/docs/adr/0001-one-spanning-tree.md. + const nodes: GraphRfNode[] = [personNode('start', true), personNode('neighbor'), pivotNode('same_ip:9.9.9.9')]; + const edges: GraphRfEdge[] = [linkEdge('start', 'neighbor'), matchEdge('same_ip:9.9.9.9', 'neighbor')]; + let prev = 'same_ip:9.9.9.9'; + for (let i = 0; i < 6; i++) { + nodes.push(personNode(`island${i}`)); + edges.push(matchEdge(prev, `island${i}`)); + prev = `island${i}`; + } + + const result = clusterGraphElements(nodes, edges, options(5, ['neighbor'])); + expect(chipsOf(result.nodes)).toHaveLength(0); + expect(result.nodes).toHaveLength(nodes.length); + }); +}); + +describe('layoutByMode', () => { + it.each(['rad-dagre', 'balanced', 'radial'] as const)('%s positions every node and wires handles', (mode) => { + const { nodes, edges } = fanFixture(6); + nodes.push(pivotNode('same_ip:1')); + edges.push(matchEdge('same_ip:1', 'c0')); + + const result = layoutByMode(mode, nodes, edges, 'start'); + + // The start node anchors the origin; 180x56 is the pre-measurement default. + const start = result.nodes.find((n) => n.id === 'start')!; + expect(start.position).toEqual({ x: -90, y: -28 }); + + // Nothing left stacked on the origin behind it. + const stacked = result.nodes.filter((n) => n.id !== 'start' && n.position.x === -90 && n.position.y === -28); + expect(stacked).toHaveLength(0); + + for (const edge of result.edges) { + expect(edge.sourceHandle).toMatch(/^s[trbl]$/); + expect(edge.targetHandle).toMatch(/^[trbl]$/); + } + }); +}); diff --git a/packages/app-builder/src/components/Graph/graph-layout.ts b/packages/app-builder/src/components/Graph/graph-layout.ts new file mode 100644 index 000000000..5f0e8c72e --- /dev/null +++ b/packages/app-builder/src/components/Graph/graph-layout.ts @@ -0,0 +1,147 @@ +import { polarPetal, radialDagre, sectoredDagre } from 'ego-graph'; +import { type FoldPlan, foldGraph } from 'ego-graph/fold'; +import { applyPositions, retargetHandles, toLayoutGraph } from 'ego-graph/react-flow'; +import { match } from 'ts-pattern'; +import { clusterNodeId } from './graph-keys'; +import { type ClusterRfNode, type GraphRfEdge, type GraphRfNode, isMatchEdge } from './graph-rf-types'; + +export type GraphLayoutMode = 'rad-dagre' | 'balanced' | 'radial'; + +/** + * Marble's half of the contract with `ego-graph`: which of our node types the + * layout must keep out of the tree, which of our edges carry hierarchy, and how + * much room a chip should be given. + */ +const CLASSIFY = { + isSatellite: (node: GraphRfNode) => node.type === 'pivot', + isStructural: (edge: GraphRfEdge) => !isMatchEdge(edge), + getWeight: (node: GraphRfNode) => (node.type === 'cluster' ? node.data.nodeCount : 1), +}; + +/** A chip is drawn like a person card, so it gets that footprint before measurement. */ +const CHIP_SIZE = { width: 192, height: 72 }; + +export function layoutByMode( + mode: GraphLayoutMode, + nodes: GraphRfNode[], + edges: GraphRfEdge[], + startKey: string, +): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { + if (nodes.length === 0) return { nodes, edges }; + + const graph = toLayoutGraph(nodes, edges, startKey); + const positions = match(mode) + .with('balanced', () => sectoredDagre(graph, CLASSIFY)) + .with('rad-dagre', () => radialDagre(graph, CLASSIFY)) + .with('radial', () => polarPetal(graph, CLASSIFY)) + .exhaustive(); + + const laid = applyPositions(nodes, positions); + return { nodes: laid, edges: retargetHandles(laid, edges) }; +} + +/** Re-point every edge at the handles facing its counterpart. */ +export function withBestHandles(nodes: GraphRfNode[], edges: GraphRfEdge[]): GraphRfEdge[] { + return retargetHandles(nodes, edges); +} + +export type ClusterOptions = { + startKey: string; + /** A branch collapses once its foldable size *exceeds* this. `0` disables clustering. */ + threshold: number; + /** Branch roots the user drilled into; their chips are suppressed. */ + expandedRootIds: Set; +}; + +/** + * Turn a {@link FoldPlan} into the chips and merged edges React Flow renders. + * + * The plan says *what* collapses; every decision about what a chip looks like — + * its id scheme, its 192x72 footprint, the person payload it carries, the + * `agg:` edge ids — lives here, because none of it means anything to a graph + * layout library. + */ +function materialise( + nodes: GraphRfNode[], + edges: GraphRfEdge[], + plan: FoldPlan, +): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { + if (plan.folds.length === 0 && plan.heldOpen.length === 0) return { nodes, edges }; + + const nodesById = new Map(nodes.map((n) => [n.id, n])); + + // A chip renders its entry point as a person card, so only a person can head + // one. Every fold root is a person in practice — the tree only ever contains + // persons and chips, and chips never reach the planner — but the payload type + // demands it, so the check stays. + const chips: ClusterRfNode[] = []; + const foldedIds = new Set(); + const chipIdByRoot = new Map(); + + for (const fold of plan.folds) { + const rootNode = nodesById.get(fold.rootId); + if (rootNode?.type !== 'person') continue; + + const chipId = clusterNodeId(fold.rootId); + chipIdByRoot.set(fold.rootId, chipId); + for (const id of fold.memberIds) foldedIds.add(id); + + chips.push({ + id: chipId, + position: { x: 0, y: 0 }, + ...CHIP_SIZE, + type: 'cluster', + data: { + root: rootNode.data, + nodeCount: fold.memberIds.length, + internalEdgeCount: fold.internalEdgeCount, + memberIds: fold.memberIds, + }, + }); + } + + const mergedEdges: GraphRfEdge[] = []; + for (const merged of plan.mergedEdges) { + const source = chipIdByRoot.get(merged.source) ?? merged.source; + const target = chipIdByRoot.get(merged.target) ?? merged.target; + // Neither end became a chip, so the original edges already say this. + if (source === merged.source && target === merged.target) continue; + + mergedEdges.push({ + id: `agg:${source}->${target}`, + source, + target, + type: merged.allAssociative ? 'match' : 'link', + animated: true, + data: { kind: merged.allAssociative ? 'match' : 'link', mergedCount: merged.mergedFrom.length }, + }); + } + + const heldOpen = new Set(plan.heldOpen); + const survivors = nodes + .filter((n) => !foldedIds.has(n.id)) + .map((n) => + n.type === 'person' && heldOpen.has(n.id) ? { ...n, data: { ...n.data, isExpandedClusterRoot: true } } : n, + ); + + const passthrough = edges.filter((e) => !foldedIds.has(e.source) && !foldedIds.has(e.target)); + + return { nodes: [...survivors, ...chips], edges: [...passthrough, ...mergedEdges] }; +} + +/** Fold branches of the graph into chips, ready for React Flow. */ +export function clusterGraphElements( + nodes: GraphRfNode[], + edges: GraphRfEdge[], + options: ClusterOptions, +): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { + if (options.threshold <= 0 || nodes.length === 0) return { nodes, edges }; + + const plan = foldGraph(toLayoutGraph(nodes, edges, options.startKey), { + ...CLASSIFY, + threshold: options.threshold, + expandedRoots: options.expandedRootIds, + }); + + return materialise(nodes, edges, plan); +} diff --git a/packages/app-builder/src/components/Graph/graph-traversal.ts b/packages/app-builder/src/components/Graph/graph-traversal.ts deleted file mode 100644 index 5dc522038..000000000 --- a/packages/app-builder/src/components/Graph/graph-traversal.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** Graph walks shared by the clustering and layout passes. Ids only, no React Flow shapes. */ - -export type SimpleEdge = { source: string; target: string }; - -/** Undirected adjacency restricted to `nodeIds`; endpoints outside the set are dropped. */ -export function buildUndirectedAdjacency(nodeIds: string[], edges: SimpleEdge[]): Map { - const idSet = new Set(nodeIds); - const adj = new Map(); - for (const id of nodeIds) { - adj.set(id, []); - } - for (const edge of edges) { - if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue; - adj.get(edge.source)!.push(edge.target); - adj.get(edge.target)!.push(edge.source); - } - return adj; -} - -/** `startKey` when it survived filtering, otherwise the first node id. `''` when there are none. */ -function resolveRoot(nodeIds: string[], startKey: string): string { - if (nodeIds.includes(startKey)) return startKey; - return nodeIds[0] ?? ''; -} - -/** Node ids reachable from `startKey` via undirected edges, including the root. */ -export function reachableNodeIds(nodeIds: string[], edges: SimpleEdge[], startKey: string): Set { - const root = resolveRoot(nodeIds, startKey); - if (!root) return new Set(); - - const adj = buildUndirectedAdjacency(nodeIds, edges); - const visited = new Set([root]); - const queue = [root]; - - while (queue.length > 0) { - const cur = queue.shift()!; - for (const nxt of adj.get(cur) ?? []) { - if (visited.has(nxt)) continue; - visited.add(nxt); - queue.push(nxt); - } - } - - return visited; -} - -/** - * BFS spanning-tree edges (parent → child) rooted at `rootId`. - * Cycles are handled by only recording the first discovery edge. - * `allowed`, when given, restricts the walk to that id set. - */ -export function bfsTreeEdges(rootId: string, adj: Map, allowed?: Set): SimpleEdge[] { - if (allowed && !allowed.has(rootId)) return []; - - const tree: SimpleEdge[] = []; - const visited = new Set([rootId]); - const queue = [rootId]; - - while (queue.length > 0) { - const cur = queue.shift()!; - for (const nxt of adj.get(cur) ?? []) { - if (visited.has(nxt) || (allowed && !allowed.has(nxt))) continue; - visited.add(nxt); - tree.push({ source: cur, target: nxt }); - queue.push(nxt); - } - } - - return tree; -} - -/** {@link bfsTreeEdges} from `startKey` over an edge list, for Dagre ranking. */ -export function bfsSpanningTreeEdges(nodeIds: string[], edges: SimpleEdge[], startKey: string): SimpleEdge[] { - const root = resolveRoot(nodeIds, startKey); - if (!root) return []; - return bfsTreeEdges(root, buildUndirectedAdjacency(nodeIds, edges)); -} - -/** Parent → children adjacency from directed tree edges. */ -export function buildChildrenMap(treeEdges: SimpleEdge[]): Map { - const children = new Map(); - for (const { source, target } of treeEdges) { - const list = children.get(source) ?? []; - list.push(target); - children.set(source, list); - } - return children; -} - -/** `rootId` and every descendant, in depth-first order. */ -export function collectSubtreeIds(children: Map, rootId: string): string[] { - const ids: string[] = []; - const stack = [rootId]; - while (stack.length > 0) { - const cur = stack.pop()!; - ids.push(cur); - const kids = children.get(cur); - if (kids) { - for (let i = kids.length - 1; i >= 0; i--) { - stack.push(kids[i]!); - } - } - } - return ids; -} diff --git a/packages/app-builder/src/components/Graph/layout-graph-2.ts b/packages/app-builder/src/components/Graph/layout-graph-2.ts deleted file mode 100644 index 2d2c06b99..000000000 --- a/packages/app-builder/src/components/Graph/layout-graph-2.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { nodeCenter, nodeMeasuredSize, type Point, topLeftFromCenter, withBestHandles } from './graph-handles'; -import { type GraphRfEdge, type GraphRfNode, isLinkEdge } from './graph-rf-types'; -import { bfsSpanningTreeEdges, buildChildrenMap, type SimpleEdge } from './graph-traversal'; -import { - type ConnectorIslandLayout, - computeArcRadius, - computeRingRadius, - dagreConnectorIslandLayout, - descendantCount, - greedySlotOrder, - lateralHalfExtent, - layoutConnectorPockets, - layoutSubtreeLocal, - type RankDir, - rankdirFromAngle, - slotAngle, -} from './layout-graph-shared'; - -const BUSHY_THRESHOLD = 5; - -function rankdirAxisAngle(rankdir: RankDir): number { - switch (rankdir) { - case 'LR': - return 0; - case 'TB': - return Math.PI / 2; - case 'RL': - return Math.PI; - case 'BT': - return -Math.PI / 2; - } -} - -function rotateOffset(dx: number, dy: number, delta: number): Point { - const c = Math.cos(delta); - const s = Math.sin(delta); - return { x: dx * c - dy * s, y: dx * s + dy * c }; -} - -/** Sector center angles: full circle at the start, outward hemisphere when nested. */ -function sectorAngles(sectorCount: number, outboundTheta: number | null): number[] { - if (sectorCount <= 0) return []; - if (outboundTheta == null) { - return Array.from({ length: sectorCount }, (_, i) => slotAngle(i, sectorCount)); - } - const span = Math.PI; - const start = outboundTheta - span / 2; - const width = span / sectorCount; - return Array.from({ length: sectorCount }, (_, i) => start + width * (i + 0.5)); -} - -function shallowEdges(parentId: string, kidIds: string[]): SimpleEdge[] { - return kidIds.map((target) => ({ source: parentId, target })); -} - -/** - * Place direct children with a shallow Dagre pocket, rotated so the rank axis - * aligns with `theta`. Optionally push the pocket outward so the innermost - * child sits at least `ringRadius` from the parent along that ray. - */ -function placeShallowPocket(args: { - parentId: string; - kidIds: string[]; - theta: number; - nodesById: Map; - positionById: Map; - ringRadius?: number; -}): number { - const { parentId, kidIds, theta, nodesById, positionById, ringRadius } = args; - if (kidIds.length === 0) return 0; - - const parentPos = positionById.get(parentId); - const parentNode = nodesById.get(parentId); - if (!parentPos || !parentNode) return 0; - - const parentSize = nodeMeasuredSize(parentNode); - const parentCenter = nodeCenter(parentNode, parentPos); - - const rankdir = rankdirFromAngle(theta); - const ids = [parentId, ...kidIds]; - const local = layoutSubtreeLocal(ids, shallowEdges(parentId, kidIds), nodesById, rankdir); - const lateralHalf = lateralHalfExtent(ids, local, nodesById, parentId, rankdir); - - const localParent = local.get(parentId); - if (!localParent) return lateralHalf; - const localParentCenter = { - x: localParent.x + parentSize.width / 2, - y: localParent.y + parentSize.height / 2, - }; - - const delta = theta - rankdirAxisAngle(rankdir); - const unit = { x: Math.cos(theta), y: Math.sin(theta) }; - - type KidPlacement = { id: string; offset: Point }; - const placements: KidPlacement[] = []; - let minProj = Infinity; - - for (const kidId of kidIds) { - const localKid = local.get(kidId); - const kidNode = nodesById.get(kidId); - if (!localKid || !kidNode) continue; - const kidSize = nodeMeasuredSize(kidNode); - const localKidCenter = { - x: localKid.x + kidSize.width / 2, - y: localKid.y + kidSize.height / 2, - }; - const rotated = rotateOffset(localKidCenter.x - localParentCenter.x, localKidCenter.y - localParentCenter.y, delta); - const proj = rotated.x * unit.x + rotated.y * unit.y; - minProj = Math.min(minProj, proj); - placements.push({ id: kidId, offset: rotated }); - } - - const push = ringRadius != null && Number.isFinite(minProj) ? Math.max(0, ringRadius - minProj) : 0; - - for (const { id, offset } of placements) { - const kidNode = nodesById.get(id)!; - const { width, height } = nodeMeasuredSize(kidNode); - const cx = parentCenter.x + offset.x + push * unit.x; - const cy = parentCenter.y + offset.y + push * unit.y; - positionById.set(id, topLeftFromCenter({ x: cx, y: cy }, width, height)); - } - - return lateralHalf; -} - -function layoutNodeRecursive(args: { - nodeId: string; - outboundTheta: number | null; - children: Map; - nodesById: Map; - positionById: Map; - l1Thetas: number[]; - isStart: boolean; -}): void { - const { nodeId, outboundTheta, children, nodesById, positionById, l1Thetas, isStart } = args; - const kidIds = children.get(nodeId) ?? []; - const n = kidIds.length; - if (n === 0) return; - - if (n < BUSHY_THRESHOLD) { - const theta = outboundTheta ?? Math.PI / 2; // TB at start - placeShallowPocket({ - parentId: nodeId, - kidIds, - theta, - nodesById, - positionById, - }); - } else { - const sectorCount = Math.ceil(n / BUSHY_THRESHOLD); - const weighted = kidIds.map((id) => ({ - id, - weight: descendantCount(children, id, nodesById), - })); - const ordered = greedySlotOrder(weighted); - const sectors: string[][] = Array.from({ length: sectorCount }, () => []); - ordered.forEach((id, i) => { - sectors[i % sectorCount]!.push(id); - }); - - const thetas = sectorAngles(sectorCount, outboundTheta); - const closed = outboundTheta == null; - - // Measure each sector pocket, then choose a shared clearance radius. - const lateralHalves: number[] = []; - for (let s = 0; s < sectorCount; s++) { - const kids = sectors[s]!; - const theta = thetas[s]!; - const rankdir = rankdirFromAngle(theta); - const ids = [nodeId, ...kids]; - const local = layoutSubtreeLocal(ids, shallowEdges(nodeId, kids), nodesById, rankdir); - lateralHalves.push(lateralHalfExtent(ids, local, nodesById, nodeId, rankdir)); - } - - const radius = closed - ? computeRingRadius(lateralHalves) - : computeArcRadius(lateralHalves, Math.PI / sectorCount, false); - - for (let s = 0; s < sectorCount; s++) { - placeShallowPocket({ - parentId: nodeId, - kidIds: sectors[s]!, - theta: thetas[s]!, - nodesById, - positionById, - ringRadius: radius, - }); - } - } - - const parentPos = positionById.get(nodeId); - const parentNode = nodesById.get(nodeId); - if (!parentPos || !parentNode) return; - const parentCenter = nodeCenter(parentNode, parentPos); - - for (const kidId of kidIds) { - const kidPos = positionById.get(kidId); - const kidNode = nodesById.get(kidId); - if (!kidPos || !kidNode) continue; - const kidCenter = nodeCenter(kidNode, kidPos); - const kidTheta = Math.atan2(kidCenter.y - parentCenter.y, kidCenter.x - parentCenter.x); - if (isStart) l1Thetas.push(kidTheta); - - layoutNodeRecursive({ - nodeId: kidId, - outboundTheta: kidTheta, - children, - nodesById, - positionById, - l1Thetas, - isStart: false, - }); - } -} - -/** - * Sector + shallow-Dagre islands under each connector (same rules as person nodes). - * Lateral half for outer-arc fanning still uses the Dagre measure. - */ -const balancedConnectorIslandLayout: ConnectorIslandLayout = { - measureLateralHalf: dagreConnectorIslandLayout.measureLateralHalf, - place({ islandIds, treeEdges, rootId, targetCenter, theta, nodesById, positionById }) { - const rootNode = nodesById.get(rootId); - if (!rootNode) return; - - const rootSize = nodeMeasuredSize(rootNode); - positionById.set(rootId, topLeftFromCenter(targetCenter, rootSize.width, rootSize.height)); - - if (islandIds.length <= 1) return; - - layoutNodeRecursive({ - nodeId: rootId, - outboundTheta: theta, - children: buildChildrenMap(treeEdges), - nodesById, - positionById, - l1Thetas: [], - isStart: false, - }); - }, -}; - -/** - * Recursive sector + shallow-Dagre layout for the start's **link** neighborhood, - * then the same balanced rules for connector islands on the outer arc. - */ -export function layoutGraphElements( - nodes: GraphRfNode[], - edges: GraphRfEdge[], - startKey: string, -): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { - if (nodes.length === 0) return { nodes, edges }; - - const nodesById = new Map(nodes.map((n) => [n.id, n])); - const resolvedStart = nodesById.has(startKey) ? startKey : (nodes[0]?.id ?? ''); - if (!resolvedStart) return { nodes, edges }; - - const personIds = nodes.filter((n) => n.type !== 'pivot').map((n) => n.id); - const pivotIds = nodes - .filter((n) => n.type === 'pivot') - .map((n) => n.id) - .sort((a, b) => a.localeCompare(b)); - const personIdSet = new Set(personIds); - - const linkEdges = edges.filter((e) => isLinkEdge(e) && personIdSet.has(e.source) && personIdSet.has(e.target)); - const treeEdges = bfsSpanningTreeEdges(personIds, linkEdges, resolvedStart); - const children = buildChildrenMap(treeEdges); - - const positionById = new Map(); - const startNode = nodesById.get(resolvedStart)!; - const startSize = nodeMeasuredSize(startNode); - const startCenter = { x: 0, y: 0 }; - positionById.set(resolvedStart, topLeftFromCenter(startCenter, startSize.width, startSize.height)); - - const l1Thetas: number[] = []; - - layoutNodeRecursive({ - nodeId: resolvedStart, - outboundTheta: null, - children, - nodesById, - positionById, - l1Thetas, - isStart: true, - }); - - layoutConnectorPockets({ - nodes, - edges, - personIds, - pivotIds, - nodesById, - positionById, - startCenter, - l1Thetas, - islandLayout: balancedConnectorIslandLayout, - }); - - const laidNodes = nodes.map((node) => { - const pos = positionById.get(node.id); - return pos ? { ...node, position: pos } : node; - }); - - return { - nodes: laidNodes, - edges: withBestHandles(laidNodes, edges), - }; -} diff --git a/packages/app-builder/src/components/Graph/layout-graph-3.ts b/packages/app-builder/src/components/Graph/layout-graph-3.ts deleted file mode 100644 index 2143e967b..000000000 --- a/packages/app-builder/src/components/Graph/layout-graph-3.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { nodeMeasuredSize, type Point, topLeftFromCenter, withBestHandles } from './graph-handles'; -import { type GraphRfEdge, type GraphRfNode, isLinkEdge } from './graph-rf-types'; -import { bfsSpanningTreeEdges, buildChildrenMap } from './graph-traversal'; -import { layoutConnectorPockets, layoutPolarTreeRecursive, radialConnectorIslandLayout } from './layout-graph-shared'; - -/** - * Pure polar “flower petal” layout for the start's **link** neighborhood, - * then radial connector islands (same petal pattern). No Dagre. - */ -export function layoutGraphElements( - nodes: GraphRfNode[], - edges: GraphRfEdge[], - startKey: string, -): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { - if (nodes.length === 0) return { nodes, edges }; - - const nodesById = new Map(nodes.map((n) => [n.id, n])); - const resolvedStart = nodesById.has(startKey) ? startKey : (nodes[0]?.id ?? ''); - if (!resolvedStart) return { nodes, edges }; - - const personIds = nodes.filter((n) => n.type !== 'pivot').map((n) => n.id); - const pivotIds = nodes - .filter((n) => n.type === 'pivot') - .map((n) => n.id) - .sort((a, b) => a.localeCompare(b)); - const personIdSet = new Set(personIds); - - const linkEdges = edges.filter((e) => isLinkEdge(e) && personIdSet.has(e.source) && personIdSet.has(e.target)); - const treeEdges = bfsSpanningTreeEdges(personIds, linkEdges, resolvedStart); - const children = buildChildrenMap(treeEdges); - - const positionById = new Map(); - const startNode = nodesById.get(resolvedStart)!; - const startSize = nodeMeasuredSize(startNode); - const startCenter = { x: 0, y: 0 }; - positionById.set(resolvedStart, topLeftFromCenter(startCenter, startSize.width, startSize.height)); - - const l1Thetas: number[] = []; - - layoutPolarTreeRecursive({ - nodeId: resolvedStart, - outboundTheta: null, - children, - nodesById, - positionById, - l1Thetas, - isStart: true, - }); - - layoutConnectorPockets({ - nodes, - edges, - personIds, - pivotIds, - nodesById, - positionById, - startCenter, - l1Thetas, - islandLayout: radialConnectorIslandLayout, - }); - - const laidNodes = nodes.map((node) => { - const pos = positionById.get(node.id); - return pos ? { ...node, position: pos } : node; - }); - - return { - nodes: laidNodes, - edges: withBestHandles(laidNodes, edges), - }; -} diff --git a/packages/app-builder/src/components/Graph/layout-graph-shared.ts b/packages/app-builder/src/components/Graph/layout-graph-shared.ts deleted file mode 100644 index 7f2254f4d..000000000 --- a/packages/app-builder/src/components/Graph/layout-graph-shared.ts +++ /dev/null @@ -1,748 +0,0 @@ -import Dagre from '@dagrejs/dagre'; -import { DEFAULT_NODE_WIDTH, nodeCenter, nodeMeasuredSize, type Point, topLeftFromCenter } from './graph-handles'; -import { type GraphRfEdge, type GraphRfNode, isMatchEdge } from './graph-rf-types'; -import { bfsTreeEdges, buildChildrenMap, buildUndirectedAdjacency, type SimpleEdge } from './graph-traversal'; - -export const NODESEP = 80; -export const RANKSEP = 100; -/** Minimum ring radius so L1 nodes clear the start node. */ -export const RING_RADIUS_MIN = 220; -/** Extra space between adjacent subtree lateral extents. */ -export const RING_PADDING = 60; -/** - * Preferred connector angles within this circular distance are treated as the - * same "side" and fanned on an outer arc instead of stacking on one ray. - */ -const CONNECTOR_CLUSTER_GAP = Math.PI / 3; -/** Minimum angular separation between fanned connectors on the outer arc. */ -const CONNECTOR_MIN_ANGLE_GAP = Math.PI / 12; - -export type RankDir = 'TB' | 'BT' | 'LR' | 'RL'; - -/** A cluster chip stands in for the whole branch it folded away. */ -export function nodeWeight(node: GraphRfNode | undefined): number { - return node?.type === 'cluster' ? node.data.nodeCount : 1; -} - -/** Subtree size including `id` itself, counting a chip as the branch it represents. */ -export function descendantCount( - children: Map, - id: string, - nodesById: Map, -): number { - let count = nodeWeight(nodesById.get(id)); - for (const child of children.get(id) ?? []) { - count += descendantCount(children, child, nodesById); - } - return count; -} - -/** - * Heaviest-first greedy: each item goes in the open slot whose nearest - * already-placed neighbors have the smallest combined weight. - */ -export function greedySlotOrder(items: Array<{ id: string; weight: number }>): string[] { - const n = items.length; - if (n === 0) return []; - - const slots: Array = Array.from({ length: n }, () => null); - const slotWeight = Array.from({ length: n }, () => 0); - const sorted = [...items].sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id)); - - for (const item of sorted) { - let bestSlot = -1; - let bestScore = Infinity; - - for (let s = 0; s < n; s++) { - if (slots[s] !== null) continue; - - let leftW = 0; - let leftDist = 0; - let rightW = 0; - let rightDist = 0; - for (let d = 1; d < n; d++) { - const left = (s - d + n) % n; - if (slots[left] !== null) { - leftW = slotWeight[left]!; - leftDist = d; - break; - } - } - for (let d = 1; d < n; d++) { - const right = (s + d) % n; - if (slots[right] !== null) { - rightW = slotWeight[right]!; - rightDist = d; - break; - } - } - - // Distance-weighted so opposite empty slots beat adjacent ones when only - // one neighbor is placed (plain sum would tie and pick the next index). - const score = (leftDist > 0 ? leftW / leftDist : 0) + (rightDist > 0 ? rightW / rightDist : 0); - if (score < bestScore || (score === bestScore && (bestSlot < 0 || s < bestSlot))) { - bestScore = score; - bestSlot = s; - } - } - - slots[bestSlot] = item.id; - slotWeight[bestSlot] = item.weight; - } - - return slots as string[]; -} - -/** Slot angle: 12 o'clock, increasing clockwise (screen y-down). */ -export function slotAngle(slotIndex: number, slotCount: number): number { - return Math.PI / 2 + (2 * Math.PI * slotIndex) / slotCount; -} - -/** Normalize angle into (-π, π]. */ -export function normalizeAngle(theta: number): number { - let a = theta; - while (a <= -Math.PI) a += 2 * Math.PI; - while (a > Math.PI) a -= 2 * Math.PI; - return a; -} - -/** Smallest signed delta from `from` to `to` in (-π, π]. */ -export function angleDelta(from: number, to: number): number { - return normalizeAngle(to - from); -} - -/** Outward Dagre rankdir from angle (nearest cardinal axis). */ -export function rankdirFromAngle(theta: number): RankDir { - const cos = Math.cos(theta); - const sin = Math.sin(theta); - if (Math.abs(cos) >= Math.abs(sin)) { - return cos >= 0 ? 'LR' : 'RL'; - } - return sin >= 0 ? 'TB' : 'BT'; -} - -/** - * Layout a subtree with Dagre. Positions are React Flow top-left. - * Returns a map of node id → position for every id in `subtreeIds`. - */ -export function layoutSubtreeLocal( - subtreeIds: string[], - treeEdges: SimpleEdge[], - nodesById: Map, - rankdir: RankDir, -): Map { - const idSet = new Set(subtreeIds); - const positions = new Map(); - - if (subtreeIds.length === 1) { - const id = subtreeIds[0]!; - const node = nodesById.get(id); - if (!node) return positions; - const { width, height } = nodeMeasuredSize(node); - positions.set(id, topLeftFromCenter({ x: 0, y: 0 }, width, height)); - return positions; - } - - const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - g.setGraph({ - rankdir, - nodesep: NODESEP, - ranksep: RANKSEP, - }); - - for (const id of subtreeIds) { - const node = nodesById.get(id); - if (!node) continue; - const { width, height } = nodeMeasuredSize(node); - g.setNode(id, { width, height }); - } - - for (const edge of treeEdges) { - if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue; - g.setEdge(edge.source, edge.target); - } - - Dagre.layout(g); - - for (const id of subtreeIds) { - const positioned = g.node(id); - const node = nodesById.get(id); - if (!positioned || !node) continue; - const { width, height } = nodeMeasuredSize(node); - positions.set(id, { - x: positioned.x - width / 2, - y: positioned.y - height / 2, - }); - } - - return positions; -} - -/** Half-extent of the subtree perpendicular to the outward (rank) axis, from the root center. */ -export function lateralHalfExtent( - subtreeIds: string[], - positions: Map, - nodesById: Map, - rootId: string, - rankdir: RankDir, -): number { - const rootPos = positions.get(rootId); - const rootNode = nodesById.get(rootId); - if (!rootPos || !rootNode) return DEFAULT_NODE_WIDTH / 2; - - const rootSize = nodeMeasuredSize(rootNode); - const rootCenter = { - x: rootPos.x + rootSize.width / 2, - y: rootPos.y + rootSize.height / 2, - }; - - const horizontal = rankdir === 'LR' || rankdir === 'RL'; - let half = horizontal ? rootSize.height / 2 : rootSize.width / 2; - - for (const id of subtreeIds) { - const pos = positions.get(id); - const node = nodesById.get(id); - if (!pos || !node) continue; - const { width, height } = nodeMeasuredSize(node); - const cx = pos.x + width / 2; - const cy = pos.y + height / 2; - if (horizontal) { - half = Math.max(half, Math.abs(cy - rootCenter.y) + height / 2); - } else { - half = Math.max(half, Math.abs(cx - rootCenter.x) + width / 2); - } - } - - return half; -} - -/** - * Ring radius for `n` items spaced over a full circle (adjacent gap `2π/n`). - */ -export function computeRingRadius(lateralHalves: number[]): number { - const n = lateralHalves.length; - if (n <= 1) return RING_RADIUS_MIN; - return computeArcRadius(lateralHalves, (2 * Math.PI) / n, true); -} - -/** - * Radius so adjacent lateral extents clear each other when placed with the - * given angular gap between neighbors. When `closed` is true the first and - * last items are also neighbors (full ring). - */ -export function computeArcRadius(lateralHalves: number[], adjacentGap: number, closed: boolean): number { - const n = lateralHalves.length; - if (n <= 1) return RING_RADIUS_MIN; - - let r = RING_RADIUS_MIN; - const sinHalf = Math.sin(adjacentGap / 2); - if (sinHalf <= 1e-6) return r; - - const pairCount = closed ? n : n - 1; - for (let i = 0; i < pairCount; i++) { - const j = (i + 1) % n; - const needed = (lateralHalves[i]! + lateralHalves[j]! + RING_PADDING) / (2 * sinHalf); - r = Math.max(r, needed); - } - return r; -} - -/** - * Preferred pocket angle for a connector: direction from the start center toward - * the centroid of already-placed match neighbors. - * - * Returns `null` when there are no placed anchors (caller should use - * {@link largestFreeGapAngle}). - */ -export function preferredConnectorAngle(startCenter: Point, placedNeighborCenters: Point[]): number | null { - if (placedNeighborCenters.length === 0) return null; - - let sx = 0; - let sy = 0; - for (const p of placedNeighborCenters) { - sx += p.x; - sy += p.y; - } - const cx = sx / placedNeighborCenters.length; - const cy = sy / placedNeighborCenters.length; - return Math.atan2(cy - startCenter.y, cx - startCenter.x); -} - -/** - * Angle in the middle of the largest gap between existing L1 slot angles. - * Used when a connector has no placed match anchors. Falls back to east (0) - * when there are no L1 slots to avoid. - */ -export function largestFreeGapAngle(l1Thetas: number[]): number { - if (l1Thetas.length === 0) return 0; - - const sorted = [...l1Thetas].map(normalizeAngle).sort((a, b) => a - b); - let bestMid = sorted[0]!; - let bestGap = -Infinity; - - for (let i = 0; i < sorted.length; i++) { - const cur = sorted[i]!; - const next = i + 1 < sorted.length ? sorted[i + 1]! : sorted[0]! + 2 * Math.PI; - const gap = next - cur; - if (gap > bestGap) { - bestGap = gap; - bestMid = normalizeAngle(cur + gap / 2); - } - } - - return bestMid; -} - -/** - * Fan connectors that share a preferred side onto an outer arc. - * - * 1. Sort by preferred angle - * 2. Cluster consecutive items within {@link CONNECTOR_CLUSTER_GAP} - * 3. Within each cluster, spread around the circular mean with separation - * driven by lateral half-extents at `radius` (same geometric idea as the - * L1 ring radius computation) - */ -export function fanConnectorAngles( - items: Array<{ id: string; preferredTheta: number; lateralHalf: number }>, - radius: number, -): Map { - const result = new Map(); - if (items.length === 0) return result; - if (items.length === 1) { - result.set(items[0]!.id, normalizeAngle(items[0]!.preferredTheta)); - return result; - } - - const sorted = [...items].sort( - (a, b) => normalizeAngle(a.preferredTheta) - normalizeAngle(b.preferredTheta) || a.id.localeCompare(b.id), - ); - - type Cluster = typeof sorted; - const clusters: Cluster[] = []; - let current: Cluster = [sorted[0]!]; - - for (let i = 1; i < sorted.length; i++) { - const prev = sorted[i - 1]!; - const item = sorted[i]!; - const gap = Math.abs(angleDelta(prev.preferredTheta, item.preferredTheta)); - if (gap > CONNECTOR_CLUSTER_GAP) { - clusters.push(current); - current = [item]; - } else { - current.push(item); - } - } - clusters.push(current); - - for (const cluster of clusters) { - if (cluster.length === 1) { - result.set(cluster[0]!.id, normalizeAngle(cluster[0]!.preferredTheta)); - continue; - } - - // Circular mean of preferred angles (average of unit vectors). - let ux = 0; - let uy = 0; - for (const item of cluster) { - ux += Math.cos(item.preferredTheta); - uy += Math.sin(item.preferredTheta); - } - const mean = Math.atan2(uy, ux); - - // Adjacent separation from lateral extents; clamp to a readable minimum. - const separations: number[] = []; - for (let i = 0; i < cluster.length - 1; i++) { - const needed = - radius > 1e-6 - ? (cluster[i]!.lateralHalf + cluster[i + 1]!.lateralHalf + RING_PADDING) / radius - : CONNECTOR_MIN_ANGLE_GAP; - separations.push(Math.max(CONNECTOR_MIN_ANGLE_GAP, needed)); - } - const totalSpan = separations.reduce((a, b) => a + b, 0); - - let cursor = mean - totalSpan / 2; - result.set(cluster[0]!.id, normalizeAngle(cursor)); - for (let i = 0; i < separations.length; i++) { - cursor += separations[i]!; - result.set(cluster[i + 1]!.id, normalizeAngle(cursor)); - } - } - - return result; -} - -/** - * Claim the still-unplaced connected component reachable from `rootId` via - * `adj`, removing claimed ids from `unplaced`. First caller wins (stable pivot - * id order is the caller's responsibility). - * - * `skipIds` (typically other pivot ids) are never entered — otherwise a shared - * match-only neighbor would let the first connector swallow sibling connectors. - * - * Returns claimed ids including `rootId`, in BFS order. - */ -export function claimUnplacedComponent( - rootId: string, - unplaced: Set, - adj: Map, - skipIds: Set = new Set(), -): string[] { - if (!unplaced.has(rootId)) return []; - - const claimed: string[] = []; - const queue = [rootId]; - unplaced.delete(rootId); - - while (queue.length > 0) { - const cur = queue.shift()!; - claimed.push(cur); - for (const nxt of adj.get(cur) ?? []) { - if (!unplaced.has(nxt)) continue; - // Other connectors stay for their own claim pass. - if (skipIds.has(nxt) && nxt !== rootId) continue; - unplaced.delete(nxt); - queue.push(nxt); - } - } - - return claimed; -} - -/** - * Radius of the outer connector pocket: clears the axis-aligned bbox of already - * placed (person) nodes from the start center, plus padding. - */ -export function computeConnectorPocketRadius( - placedCentersWithSize: Array<{ center: Point; width: number; height: number }>, - startCenter: Point, -): number { - let r = RING_RADIUS_MIN; - for (const { center, width, height } of placedCentersWithSize) { - const dx = Math.abs(center.x - startCenter.x) + width / 2; - const dy = Math.abs(center.y - startCenter.y) + height / 2; - // Conservative clearance: distance to farthest bbox corner from start. - const dist = Math.hypot(center.x - startCenter.x, center.y - startCenter.y); - const halfDiag = Math.hypot(width / 2, height / 2); - r = Math.max(r, dist + halfDiag + RING_PADDING, dx + RING_PADDING, dy + RING_PADDING); - } - return r; -} - -/** - * Translate a locally-laid-out subtree so `rootId`'s center lands on `targetCenter`. - */ -export function placeSubtreeAt( - subtreeIds: string[], - localPositions: Map, - nodesById: Map, - rootId: string, - targetCenter: Point, - positionById: Map, -): void { - const localRoot = localPositions.get(rootId); - const rootNode = nodesById.get(rootId); - if (!localRoot || !rootNode) return; - - const rootSize = nodeMeasuredSize(rootNode); - const localRootCenter = { - x: localRoot.x + rootSize.width / 2, - y: localRoot.y + rootSize.height / 2, - }; - const dx = targetCenter.x - localRootCenter.x; - const dy = targetCenter.y - localRootCenter.y; - - for (const nodeId of subtreeIds) { - const local = localPositions.get(nodeId); - if (!local) continue; - positionById.set(nodeId, { x: local.x + dx, y: local.y + dy }); - } -} - -/** How to size and place a connector's unplaced island under its pivot. */ -export type ConnectorIslandLayout = { - measureLateralHalf: (args: { - islandIds: string[]; - treeEdges: SimpleEdge[]; - rootId: string; - preferredTheta: number; - nodesById: Map; - }) => number; - place: (args: { - islandIds: string[]; - treeEdges: SimpleEdge[]; - rootId: string; - targetCenter: Point; - theta: number; - nodesById: Map; - positionById: Map; - }) => void; -}; - -/** Default: Dagre subtree translated so the pivot sits on the outer pocket. */ -export const dagreConnectorIslandLayout: ConnectorIslandLayout = { - measureLateralHalf({ islandIds, treeEdges, rootId, preferredTheta, nodesById }) { - const rankdir = rankdirFromAngle(preferredTheta); - const localPositions = layoutSubtreeLocal(islandIds, treeEdges, nodesById, rankdir); - return lateralHalfExtent(islandIds, localPositions, nodesById, rootId, rankdir); - }, - place({ islandIds, treeEdges, rootId, targetCenter, theta, nodesById, positionById }) { - const localPositions = layoutSubtreeLocal(islandIds, treeEdges, nodesById, rankdirFromAngle(theta)); - placeSubtreeAt(islandIds, localPositions, nodesById, rootId, targetCenter, positionById); - }, -}; - -/** Sector center angles: full circle when `outboundTheta` is null, else outward hemisphere. */ -export function polarSectorAngles(sectorCount: number, outboundTheta: number | null): number[] { - if (sectorCount <= 0) return []; - if (outboundTheta == null) { - return Array.from({ length: sectorCount }, (_, i) => slotAngle(i, sectorCount)); - } - const span = Math.PI; - const start = outboundTheta - span / 2; - const width = span / sectorCount; - return Array.from({ length: sectorCount }, (_, i) => start + width * (i + 0.5)); -} - -/** Half-extent of an axis-aligned box perpendicular to angle `theta`. */ -export function aabbHalfPerp(width: number, height: number, theta: number): number { - return (width / 2) * Math.abs(Math.sin(theta)) + (height / 2) * Math.abs(Math.cos(theta)); -} - -/** Half-extent of an axis-aligned box along angle `theta`. */ -export function aabbHalfRadial(width: number, height: number, theta: number): number { - return (width / 2) * Math.abs(Math.cos(theta)) + (height / 2) * Math.abs(Math.sin(theta)); -} - -/** - * Pure polar flower-petal placement: 360° at a root (`outboundTheta == null`), - * else 180° hemisphere centered on the outbound ray. - */ -export function layoutPolarTreeRecursive(args: { - nodeId: string; - outboundTheta: number | null; - children: Map; - nodesById: Map; - positionById: Map; - l1Thetas: number[]; - isStart: boolean; -}): void { - const { nodeId, outboundTheta, children, nodesById, positionById, l1Thetas, isStart } = args; - const kidIds = children.get(nodeId) ?? []; - const n = kidIds.length; - if (n === 0) return; - - const parentPos = positionById.get(nodeId); - const parentNode = nodesById.get(nodeId); - if (!parentPos || !parentNode) return; - - const parentSize = nodeMeasuredSize(parentNode); - const parentCenter = nodeCenter(parentNode, parentPos); - - const weighted = kidIds.map((id) => ({ - id, - weight: descendantCount(children, id, nodesById), - })); - const ordered = greedySlotOrder(weighted); - const thetas = polarSectorAngles(n, outboundTheta); - const closed = outboundTheta == null; - - const lateralHalves = ordered.map((id, i) => { - const kidNode = nodesById.get(id)!; - const { width, height } = nodeMeasuredSize(kidNode); - return aabbHalfPerp(width, height, thetas[i]!); - }); - - const siblingRadius = closed ? computeRingRadius(lateralHalves) : computeArcRadius(lateralHalves, Math.PI / n, false); - - for (let i = 0; i < ordered.length; i++) { - const kidId = ordered[i]!; - const theta = thetas[i]!; - const kidNode = nodesById.get(kidId)!; - const kidSize = nodeMeasuredSize(kidNode); - const parentClearance = - aabbHalfRadial(parentSize.width, parentSize.height, theta) + - aabbHalfRadial(kidSize.width, kidSize.height, theta) + - RING_PADDING; - const r = Math.max(siblingRadius, parentClearance); - const cx = parentCenter.x + r * Math.cos(theta); - const cy = parentCenter.y + r * Math.sin(theta); - positionById.set(kidId, topLeftFromCenter({ x: cx, y: cy }, kidSize.width, kidSize.height)); - - if (isStart) l1Thetas.push(theta); - - layoutPolarTreeRecursive({ - nodeId: kidId, - outboundTheta: theta, - children, - nodesById, - positionById, - l1Thetas, - isStart: false, - }); - } -} - -/** Polar petal islands under each connector (same pattern as the radial person tree). */ -export const radialConnectorIslandLayout: ConnectorIslandLayout = { - measureLateralHalf({ islandIds, preferredTheta, nodesById }) { - let half = 0; - for (const id of islandIds) { - const node = nodesById.get(id); - if (!node) continue; - const { width, height } = nodeMeasuredSize(node); - half = Math.max(half, aabbHalfPerp(width, height, preferredTheta)); - } - return half; - }, - place({ islandIds, treeEdges, rootId, targetCenter, theta, nodesById, positionById }) { - const rootNode = nodesById.get(rootId); - if (!rootNode) return; - - const rootSize = nodeMeasuredSize(rootNode); - positionById.set(rootId, topLeftFromCenter(targetCenter, rootSize.width, rootSize.height)); - - if (islandIds.length <= 1) return; - - layoutPolarTreeRecursive({ - nodeId: rootId, - outboundTheta: theta, - children: buildChildrenMap(treeEdges), - nodesById, - positionById, - l1Thetas: [], - isStart: false, - }); - }, -}; - -/** - * Outer-arc side pockets for match/connector (pivot) nodes. - * Mutates `positionById` in place. - */ -export function layoutConnectorPockets(args: { - nodes: GraphRfNode[]; - edges: GraphRfEdge[]; - personIds: string[]; - pivotIds: string[]; - nodesById: Map; - positionById: Map; - startCenter: Point; - l1Thetas: number[]; - /** Defaults to {@link dagreConnectorIslandLayout}. */ - islandLayout?: ConnectorIslandLayout; -}): void { - const { - nodes, - edges, - personIds, - pivotIds, - nodesById, - positionById, - startCenter, - l1Thetas, - islandLayout = dagreConnectorIslandLayout, - } = args; - if (pivotIds.length === 0) return; - - const allIds = nodes.map((n) => n.id); - const fullAdj = buildUndirectedAdjacency( - allIds, - edges.map((e) => ({ source: e.source, target: e.target })), - ); - - const matchAdj = new Map(); - for (const id of pivotIds) { - matchAdj.set(id, []); - } - for (const edge of edges) { - if (!isMatchEdge(edge)) continue; - if (pivotIds.includes(edge.source)) { - matchAdj.get(edge.source)!.push(edge.target); - } - if (pivotIds.includes(edge.target)) { - matchAdj.get(edge.target)!.push(edge.source); - } - } - - const unplaced = new Set(allIds.filter((id) => !positionById.has(id))); - const islands = new Map(); - const pivotIdSet = new Set(pivotIds); - - for (const pivotId of pivotIds) { - if (!unplaced.has(pivotId)) continue; - islands.set(pivotId, claimUnplacedComponent(pivotId, unplaced, fullAdj, pivotIdSet)); - } - - const placedPersonBoxes: Array<{ center: Point; width: number; height: number }> = []; - for (const id of personIds) { - const pos = positionById.get(id); - const node = nodesById.get(id); - if (!pos || !node) continue; - const size = nodeMeasuredSize(node); - placedPersonBoxes.push({ center: nodeCenter(node, pos), width: size.width, height: size.height }); - } - const pocketRadius = computeConnectorPocketRadius(placedPersonBoxes, startCenter); - - type IslandPrep = { - id: string; - preferredTheta: number; - islandIds: string[]; - treeEdges: SimpleEdge[]; - lateralHalf: number; - }; - - const prepared: IslandPrep[] = []; - for (const pivotId of pivotIds) { - const islandIds = islands.get(pivotId); - if (!islandIds || islandIds.length === 0) continue; - - const placedNeighborCenters: Point[] = []; - for (const neighborId of matchAdj.get(pivotId) ?? []) { - const pos = positionById.get(neighborId); - const node = nodesById.get(neighborId); - if (!pos || !node) continue; - placedNeighborCenters.push(nodeCenter(node, pos)); - } - - const preferred = preferredConnectorAngle(startCenter, placedNeighborCenters) ?? largestFreeGapAngle(l1Thetas); - - const islandTree = bfsTreeEdges(pivotId, fullAdj, new Set(islandIds)); - const lateralHalf = islandLayout.measureLateralHalf({ - islandIds, - treeEdges: islandTree, - rootId: pivotId, - preferredTheta: preferred, - nodesById, - }); - - prepared.push({ - id: pivotId, - preferredTheta: preferred, - islandIds, - treeEdges: islandTree, - lateralHalf, - }); - } - - const finalAngles = fanConnectorAngles( - prepared.map((p) => ({ - id: p.id, - preferredTheta: p.preferredTheta, - lateralHalf: p.lateralHalf, - })), - pocketRadius, - ); - - for (const prep of prepared) { - const theta = finalAngles.get(prep.id) ?? prep.preferredTheta; - islandLayout.place({ - islandIds: prep.islandIds, - treeEdges: prep.treeEdges, - rootId: prep.id, - targetCenter: { x: pocketRadius * Math.cos(theta), y: pocketRadius * Math.sin(theta) }, - theta, - nodesById, - positionById, - }); - } -} diff --git a/packages/app-builder/src/components/Graph/layout-graph.ts b/packages/app-builder/src/components/Graph/layout-graph.ts deleted file mode 100644 index a5db56a35..000000000 --- a/packages/app-builder/src/components/Graph/layout-graph.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { nodeMeasuredSize, type Point, topLeftFromCenter, withBestHandles } from './graph-handles'; -import { type GraphRfEdge, type GraphRfNode, isLinkEdge } from './graph-rf-types'; -import { bfsSpanningTreeEdges, buildChildrenMap, collectSubtreeIds } from './graph-traversal'; -import { - claimUnplacedComponent, - computeRingRadius, - descendantCount, - fanConnectorAngles, - greedySlotOrder, - largestFreeGapAngle, - lateralHalfExtent, - layoutConnectorPockets, - layoutSubtreeLocal, - normalizeAngle, - placeSubtreeAt, - preferredConnectorAngle, - rankdirFromAngle, - slotAngle, -} from './layout-graph-shared'; - -export { - claimUnplacedComponent, - fanConnectorAngles, - greedySlotOrder, - largestFreeGapAngle, - normalizeAngle, - preferredConnectorAngle, - rankdirFromAngle, - slotAngle, -}; - -/** - * Radial L1 ring + per-subtree Dagre for the start's **link** neighborhood, - * then outer-arc side pockets for match/connector (pivot) nodes. - * - * Match edges never participate in the person spanning tree — connectors are - * cross-cutting and would otherwise warp the star (deep Dagre branches). - * All edges remain for rendering / handle targeting. - */ -export function layoutGraphElements( - nodes: GraphRfNode[], - edges: GraphRfEdge[], - startKey: string, -): { nodes: GraphRfNode[]; edges: GraphRfEdge[] } { - if (nodes.length === 0) return { nodes, edges }; - - const nodesById = new Map(nodes.map((n) => [n.id, n])); - const resolvedStart = nodesById.has(startKey) ? startKey : (nodes[0]?.id ?? ''); - if (!resolvedStart) return { nodes, edges }; - - const personIds = nodes.filter((n) => n.type !== 'pivot').map((n) => n.id); - const pivotIds = nodes - .filter((n) => n.type === 'pivot') - .map((n) => n.id) - .sort((a, b) => a.localeCompare(b)); - const personIdSet = new Set(personIds); - - // Link-only edges among non-pivots drive the start-centered radial star. - const linkEdges = edges.filter((e) => isLinkEdge(e) && personIdSet.has(e.source) && personIdSet.has(e.target)); - - const treeEdges = bfsSpanningTreeEdges(personIds, linkEdges, resolvedStart); - const children = buildChildrenMap(treeEdges); - const l1Ids = children.get(resolvedStart) ?? []; - - const positionById = new Map(); - - const startNode = nodesById.get(resolvedStart)!; - const startSize = nodeMeasuredSize(startNode); - const startCenter = { x: 0, y: 0 }; - positionById.set(resolvedStart, topLeftFromCenter(startCenter, startSize.width, startSize.height)); - - const l1Thetas: number[] = []; - - if (l1Ids.length > 0) { - const weighted = l1Ids.map((id) => ({ - id, - weight: descendantCount(children, id, nodesById), - })); - const orderedL1 = greedySlotOrder(weighted); - const n = orderedL1.length; - - type SubtreeLayout = { - id: string; - theta: number; - subtreeIds: string[]; - localPositions: Map; - lateralHalf: number; - }; - - const subtreeLayouts: SubtreeLayout[] = orderedL1.map((id, slotIndex) => { - const theta = slotAngle(slotIndex, n); - l1Thetas.push(theta); - const rankdir = rankdirFromAngle(theta); - const subtreeIds = collectSubtreeIds(children, id); - const localPositions = layoutSubtreeLocal(subtreeIds, treeEdges, nodesById, rankdir); - const lateralHalf = lateralHalfExtent(subtreeIds, localPositions, nodesById, id, rankdir); - return { id, theta, subtreeIds, localPositions, lateralHalf }; - }); - - const radius = computeRingRadius(subtreeLayouts.map((s) => s.lateralHalf)); - - for (const layout of subtreeLayouts) { - placeSubtreeAt( - layout.subtreeIds, - layout.localPositions, - nodesById, - layout.id, - { x: radius * Math.cos(layout.theta), y: radius * Math.sin(layout.theta) }, - positionById, - ); - } - } - - layoutConnectorPockets({ - nodes, - edges, - personIds, - pivotIds, - nodesById, - positionById, - startCenter, - l1Thetas, - }); - - const laidNodes = nodes.map((node) => { - const pos = positionById.get(node.id); - return pos ? { ...node, position: pos } : node; - }); - - return { - nodes: laidNodes, - edges: withBestHandles(laidNodes, edges), - }; -} diff --git a/packages/ego-graph/.gitignore b/packages/ego-graph/.gitignore new file mode 100644 index 000000000..f580a66a9 --- /dev/null +++ b/packages/ego-graph/.gitignore @@ -0,0 +1,8 @@ +dist +node_modules +.cursor +.claude + +# Published glossary — the repo root ignores CONTEXT.md as a local agent file, +# but here it is part of the package and the README links to it. +!CONTEXT.md diff --git a/packages/ego-graph/CONTEXT.md b/packages/ego-graph/CONTEXT.md new file mode 100644 index 000000000..f4c649799 --- /dev/null +++ b/packages/ego-graph/CONTEXT.md @@ -0,0 +1,66 @@ +# Context: ego-graph + +The vocabulary of this package. Glossary only — no implementation, no decisions. +For decisions see [docs/adr](./docs/adr). + +## Terms + +**Ego graph** — a graph seen from one node. Not a general graph: every layout +here answers "what surrounds *this* node", and every distance is measured from it. + +**Root** — the node the layout centres on. The ego. Its centre is the origin of +the coordinate system the layout returns. + +**Structural edge** — an edge that defines the hierarchy the layout draws. The +spanning tree is built from these and nothing else. + +**Associative edge** — an edge that exists and is rendered, but must never +influence position. Two nodes can be associated without one being under the +other. An associative edge between two ordinary nodes is legal and common. + +The distinction is *not* importance. An associative edge may be the most +significant relationship in the domain; it is simply not hierarchical. + +**Satellite** — a node that never joins the spanning tree, whatever edges it +has. A role, not a consequence: declaring a node a satellite settles its +placement regardless of edge classes. Satellites are parked on the periphery so +they cannot reparent branches or warp the ring. + +Satellite and edge class are **orthogonal axes**. A satellite may have structural +edges (it still stays out of the tree); an ordinary node may have only +associative ones (it still is not a satellite). + +**Spanning tree** — the one hierarchy every pass agrees on: structural edges +between non-satellite nodes, breadth-first from the root. There is exactly one +definition, shared by layout and folding. + +**Ring** — the circle of level-1 branches around the root. + +**Slot** — one angular position on a ring. **Sector** — a wedge of the circle +allotted to a group of children. + +**Pocket** — the angular region allotted to one satellite and the island hanging +off it, on an arc outside everything the tree placed. + +**Island** — a connected group of nodes the spanning tree could not reach, +claimed by a satellite (or, if no satellite touches it, standing alone as an +**orphan** island). Every island gets a pocket. + +**Lateral half-extent** — half the width of a laid-out subtree, measured +perpendicular to the ray it grows along. The packing measure: two neighbouring +branches clear each other when the ring is wide enough for both half-extents +plus padding. + +**Fold** — a branch collapsed behind a single stand-in. **Folded group** — the +nodes that disappeared. A fold names the branch by its **root**; the fold's +members include that root. + +**Fold plan** — what to collapse and what to redraw, with no opinion about how. +The package emits a plan; the caller materialises whatever a collapsed branch +should look like. + +## Words this package does not use + +`pivot`, `connector`, `person`, `company`, `match edge`, `link edge`, `cluster`, +`chip`, `start`. They come from the domain this code was extracted from and mean +nothing to anyone else. diff --git a/packages/ego-graph/LICENSE b/packages/ego-graph/LICENSE new file mode 100644 index 000000000..801037e44 --- /dev/null +++ b/packages/ego-graph/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 Checkmarble + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/ego-graph/README.md b/packages/ego-graph/README.md new file mode 100644 index 000000000..5542aea0e --- /dev/null +++ b/packages/ego-graph/README.md @@ -0,0 +1,179 @@ +# ego-graph + +Radial layouts for **ego graphs** — a graph seen from one node — with optional +branch folding. Built for [React Flow](https://reactflow.dev), but the core +knows nothing about it. + +Most layout libraries draw hierarchies (`dagre`, `elkjs`) or let physics decide +(`d3-force`). Neither is what you want when there is one node the whole picture +is *about*: a customer and their connections, a person and their network, an +account and everything touching it. That is an ego graph, and it wants the root +in the middle with its neighbourhood arranged around it. + +## Install + +```sh +npm install ego-graph +``` + +Zero dependencies. `@dagrejs/dagre` is an **optional** peer — install it only if +you use `radialDagre` or `sectoredDagre`: + +```sh +npm install ego-graph @dagrejs/dagre +``` + +## Quick start + +```ts +import { radialDagre } from 'ego-graph'; +import { applyPositions, retargetHandles, toLayoutGraph } from 'ego-graph/react-flow'; + +const graph = toLayoutGraph(nodes, edges, 'customer-42'); +const positions = radialDagre(graph); + +const laidOut = applyPositions(nodes, positions); +const rewired = retargetHandles(laidOut, edges); +``` + +The core takes `{ id, width, height }` nodes and `{ source, target }` edges and +returns `Map` of **top-left** positions. It is synchronous, has no +dependencies, and does not import React. The `react-flow` adapter is four pure +functions and does not import React either. + +## The three layouts + +All three centre the root at the origin and place unreachable nodes in pockets +on an outer arc. They differ only in how a branch is drawn. + +| | | dependency | +|---|---|---| +| `radialDagre` | A ring of level-1 branches, each drawn by Dagre pointing outward. The workhorse — deep, uneven branches stay legible. | `@dagrejs/dagre` | +| `sectoredDagre` | Every node lays out its own children, splitting them across sectors once there are more than five. Balances better on wide, shallow graphs. | `@dagrejs/dagre` | +| `polarPetal` | Pure polar placement all the way down: each node spreads its children over a hemisphere centred on the ray it arrived along. | **none** | + +`@dagrejs/dagre` is an **optional peer dependency**. It is reachable from +exactly one module, so importing only `polarPetal` leaves it out of your bundle +entirely — 8 kB minified against 49 kB. + +## Classifying your graph + +Two optional predicates, and they are **orthogonal axes**: + +```ts +radialDagre(graph, { + // Does this edge define hierarchy? Default: every edge does. + isStructural: (edge) => edge.type !== 'same-ip', + // Keep this node out of the tree and park it on the periphery. Default: none. + isSatellite: (node) => node.type === 'shared-attribute', + // How many nodes does this one stand for? Default: 1. + getWeight: (node) => node.data.memberCount ?? 1, +}); +``` + +An **associative** edge (one where `isStructural` is false) is still rendered — +it simply never influences position. This is not the same as being unimportant; +it may be the most interesting relationship you have. It is just not +hierarchical. + +A **satellite** never joins the tree no matter what edges it has. That is the +point: an attribute shared by six otherwise-unrelated people would, if allowed +into the hierarchy, drag all six under it and wreck the star. Instead it gets a +pocket on the outer arc, with its own island laid out locally. + +See [CONTEXT.md](./CONTEXT.md) for the full vocabulary. + +## Folding + +For graphs too large to show at once, collapse whole branches: + +```ts +import { foldGraph } from 'ego-graph/fold'; + +const plan = foldGraph(graph, { threshold: 10, isSatellite, isStructural }); +// { folds: [{ rootId, memberIds, internalEdgeCount }], +// mergedEdges: [{ source, target, mergedFrom, allAssociative }], +// heldOpen: string[], foldOf: Map } +``` + +`foldGraph` returns a **plan**, never nodes. It says *what* collapses; you decide +what a collapsed branch looks like — its id, its size, its label, its payload. +The shallowest branch exceeding `threshold` folds, so expanding one re-applies +the rule to its children and folds nest. Pass `expandedRoots` to hold specific +branches open; they come back in `heldOpen` so you can offer a regroup control. + +## Layout runs before React Flow measures + +This bites everyone once. React Flow does not know how big a node is until it +has rendered it, so the first layout pass runs on estimates. You need two: + +```tsx +function AutoLayout({ nodes, edges, root }) { + const { setNodes, setEdges } = useReactFlow(); + const nodesInitialized = useNodesInitialized(); + + useEffect(() => { + if (!nodesInitialized) return; + const positions = radialDagre(toLayoutGraph(nodes, edges, root)); + const laidOut = applyPositions(nodes, positions); + setNodes(laidOut); + setEdges(retargetHandles(laidOut, edges)); + }, [nodesInitialized]); + + return null; +} +``` + +Render it as a child of ``. Until then, `toLayoutGraph` falls back to +180×56 per node; pass a fourth argument to change that. + +## Edge handles + +`retargetHandles` re-points every edge at whichever handle faces its counterpart, +which is what stops edges from crossing their own nodes after a radial layout. +For that to work your node must actually **render the handles it names**. Eight +of them, invisible: + +```tsx +import { Handle, Position } from '@xyflow/react'; + +function OmniHandles() { + const style = { opacity: 0, border: 0 }; + return ( + <> + + + + + + + + + + ); +} +``` + +Bare sides for targets, `s`-prefixed for sources. If your nodes name handles +differently, pass a mapper: `retargetHandles(nodes, edges, (side, kind) => ...)`. + +## Spacing + +Six dials, all optional. Defaults are tuned for roughly 180×56 nodes — if yours +are much bigger, scale them up, because nothing here derives spacing from the +nodes it is spacing. + +| option | default | | +|---|---|---| +| `nodeSep` | `80` | gap between siblings inside a Dagre sub-layout | +| `rankSep` | `100` | gap between ranks inside a Dagre sub-layout | +| `minRingRadius` | `220` | floor on the first ring, so level-1 clears the root | +| `ringPadding` | `60` | extra space between adjacent branches | +| `satelliteGap` | `π/3` | satellites within this angle of each other get fanned | +| `minSatelliteGap` | `π/12` | floor on the gap between fanned satellites | + +Exported as `DEFAULT_SPACING`. + +## Licence + +MIT diff --git a/packages/ego-graph/RELEASING.md b/packages/ego-graph/RELEASING.md new file mode 100644 index 000000000..fa23c59ae --- /dev/null +++ b/packages/ego-graph/RELEASING.md @@ -0,0 +1,62 @@ +# Releasing + +**This directory is the source of truth.** `github.com/checkmarble/ego-graph` is a +mirror, regenerated from here on every release. Never commit to the mirror +directly — the next release overwrites it. + +The one file that differs: the mirror needs its own `biome.json`, because here +the monorepo root config governs. Everything else is identical, deliberately, so +the split is lossless. + +## Release + +From the repo root, with a clean tree: + +```sh +# 1. Everything green +bun run -F ego-graph type-check +bun run -F ego-graph unit-tests +bun run -F ego-graph build +bun run -F ego-graph check-package + +# 2. Bump the version in packages/ego-graph/package.json, commit. + +# 3. Regenerate the mirror +git subtree split --prefix=packages/ego-graph -b ego-graph-release + +# 4. Push it (force: the split rewrites history each time) +git push --force git@github.com:checkmarble/ego-graph.git ego-graph-release:main + +# 5. Publish from a fresh clone of the mirror +git clone git@github.com:checkmarble/ego-graph.git /tmp/ego-graph-publish +cd /tmp/ego-graph-publish +bun install +# biome.json is mirror-only; add it if the lint script is needed there. +npm publish --otp= + +# 6. Tag +git tag v && git push --tags +``` + +`prepublishOnly` rebuilds and runs `publint` + `attw`, so a malformed package +cannot get out. + +## npm requires 2FA + +`npm publish` fails with `E403 ... Two-factor authentication or granular access +token with bypass 2fa enabled is required`. Two ways through: + +- **Publishing by hand:** enable 2FA on the npm account, then pass `--otp=`. +- **From CI:** create a *granular* access token with "Bypass 2FA" enabled, scoped + to this package, and set `NODE_AUTH_TOKEN`. Classic automation tokens no + longer satisfy the requirement. + +## Gotchas + +- **Keep the `unit-tests` script.** The workspace runs `bun run -F '*' + unit-tests`; rename it and this package silently drops out of `test:all`. +- **`CONTEXT.md` is force-included** by this directory's `.gitignore`. The repo + root ignores that filename as a local agent file, and without the negation the + subtree split drops the glossary the README links to. +- **`tsconfig.json` is self-contained**, not extending the monorepo base. That is + what lets the same file work in the mirror. Do not re-add `extends`. diff --git a/packages/ego-graph/docs/adr/0001-one-spanning-tree.md b/packages/ego-graph/docs/adr/0001-one-spanning-tree.md new file mode 100644 index 000000000..4eb818c2e --- /dev/null +++ b/packages/ego-graph/docs/adr/0001-one-spanning-tree.md @@ -0,0 +1,69 @@ +# 1. One spanning tree, shared by layout and folding + +Date: 2026-08-07 + +## Status + +Accepted. + +## Context + +This code was extracted from an application that built **two different spanning +trees from the same graph**, and used them for two different passes: + +- **Folding** walked *every* edge, satellites included. A group of nodes + reachable only through a satellite was therefore a branch, and could collapse + into a single stand-in. +- **Layout** walked structural edges between non-satellite nodes only. This was + deliberate: satellites are cross-cutting, and letting them into the tree turns + a tidy star into a set of deep, lopsided branches. + +Two functions over one graph disagreeing about what a branch is, is not +something you can put in a README. + +Worse, the folding tree was **order-dependent**. A satellite discovered at +breadth-first depth *d* makes all its neighbours depth *d+1*, stealing them from +deeper structural parents. In our own fixture, swapping the position of two +edges in the input array moved three nodes from one branch to another and +changed a branch's weight from 3 to 1 — different folds, same graph. + +## Decision + +There is one spanning tree: **structural edges between non-satellite nodes, +breadth-first from the root**. Both folding and layout consume it. + +Satellites are excluded when the tree is built, not filtered out afterwards. + +## Consequences + +**A group reachable only through a satellite is no longer a branch, and will not +fold.** It stays on canvas, and the layout gives it a pocket. This is a real +capability loss and the reason this decision is worth recording — someone will +eventually ask why a visibly cohesive cluster refuses to collapse. + +We measured the loss before accepting it. Across every hand-written fixture in +the originating application, at all eight threshold settings the UI offered, the +output is **byte-identical**. The difference only appears on generated graphs +large and dense enough for satellite-owned islands to exceed the fold threshold. + +**Fold boundaries are now independent of input edge order.** This is a bug fix +we got for free, and on balance is worth more than the capability lost. + +**Two blocks of the folding code became dead** and were deleted: the filter +removing satellites from a fold's members, and the guard for a fold with no +members. Both existed only to compensate for satellites being in the tree. + +**One test was rewritten rather than repaired.** It asserted that an island +owned by a satellite collapses into a chip — precisely the behaviour being +removed. It now asserts the opposite: the island stays unfolded and on canvas. + +## Alternatives considered + +**Keep both trees and document the difference.** Zero behaviour change, all +existing tests stay green. Rejected: it makes the package's central concept +conditional on which function you call, and preserves the order-dependence bug. + +**Let the caller supply the tree.** Most flexible, and makes the tree a +first-class inspectable value. Rejected for v1 as it adds a step to every +example for a generality nobody has asked for. `buildSpanningTree` is exported, +so this stays available later without a breaking change. diff --git a/packages/ego-graph/package.json b/packages/ego-graph/package.json new file mode 100644 index 000000000..c3b36ac90 --- /dev/null +++ b/packages/ego-graph/package.json @@ -0,0 +1,81 @@ +{ + "name": "ego-graph", + "version": "0.1.0", + "description": "Radial ego-network layouts with branch folding, for React Flow and anything else", + "keywords": [ + "graph", + "layout", + "react-flow", + "xyflow", + "ego-network", + "radial", + "dagre", + "visualization" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/checkmarble/ego-graph.git" + }, + "homepage": "https://github.com/checkmarble/ego-graph#readme", + "bugs": "https://github.com/checkmarble/ego-graph/issues", + "sideEffects": false, + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./fold": { + "types": "./dist/fold.d.mts", + "import": "./dist/fold.mjs" + }, + "./react-flow": { + "types": "./dist/react-flow.d.mts", + "import": "./dist/react-flow.mjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "fold": [ + "./dist/fold.d.mts" + ], + "react-flow": [ + "./dist/react-flow.d.mts" + ] + } + }, + "scripts": { + "build": "tsdown", + "lint": "biome lint", + "type-check": "tsc --noEmit", + "test": "vitest run", + "check-package": "publint --strict && attw --pack . --ignore-rules cjs-resolves-to-esm", + "prepublishOnly": "bun run build && bun run check-package", + "unit-tests": "vitest run" + }, + "peerDependencies": { + "@dagrejs/dagre": "^3.0.0" + }, + "peerDependenciesMeta": { + "@dagrejs/dagre": { + "optional": true + } + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@dagrejs/dagre": "^3.0.0", + "publint": "^0.3.23", + "tsdown": "^0.22.14", + "typescript": "^5.8.2", + "vite": "^7.3.6", + "vite-tsconfig-paths": "6.1.1", + "vitest": "4.1.8" + } +} diff --git a/packages/ego-graph/src/fold/fold.spec.ts b/packages/ego-graph/src/fold/fold.spec.ts new file mode 100644 index 000000000..97b8ad998 --- /dev/null +++ b/packages/ego-graph/src/fold/fold.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { classify, graph, link, match, node, satellite, type TestEdge, type TestNode } from '../test-support'; +import { buildSpanningTree } from '../tree'; +import { type FoldOptions, foldGraph } from './index'; + +/** `start -- root -- c0..c{n-1}`, a fan of `n` leaves under a single root. */ +function fanFixture(n: number): { nodes: TestNode[]; edges: TestEdge[] } { + const nodes: TestNode[] = [node('start'), node('root')]; + const edges: TestEdge[] = [link('start', 'root')]; + for (let i = 0; i < n; i++) { + nodes.push(node(`c${i}`)); + edges.push(link('root', `c${i}`)); + } + return { nodes, edges }; +} + +function options(threshold: number, expanded: string[] = []): FoldOptions { + return { ...classify, threshold, expandedRoots: new Set(expanded) }; +} + +function fold(nodes: TestNode[], edges: TestEdge[], opts: FoldOptions) { + return foldGraph(graph(nodes, edges, 'start'), opts); +} + +describe('foldGraph', () => { + it('plans nothing when the threshold is 0', () => { + const { nodes, edges } = fanFixture(20); + const plan = fold(nodes, edges, options(0)); + + expect(plan.folds).toHaveLength(0); + expect(plan.mergedEdges).toHaveLength(0); + expect(plan.foldOf.size).toBe(0); + }); + + it('leaves a branch below the threshold untouched', () => { + const { nodes, edges } = fanFixture(3); + expect(fold(nodes, edges, options(10)).folds).toHaveLength(0); + }); + + it('leaves a branch exactly on the threshold untouched', () => { + // root + 9 children = 10 foldable nodes; a threshold of 10 is not exceeded. + const { nodes, edges } = fanFixture(9); + expect(fold(nodes, edges, options(10)).folds).toHaveLength(0); + }); + + it('collapses a branch above the threshold into one fold', () => { + // root + 9 children = 10 foldable nodes, one over the threshold of 9. + const { nodes, edges } = fanFixture(9); + const plan = fold(nodes, edges, options(9)); + + expect(plan.folds).toHaveLength(1); + const [only] = plan.folds; + expect(only!.rootId).toBe('root'); + expect(only!.memberIds).toHaveLength(10); + expect(only!.memberIds).toContain('root'); + // The 9 root->child edges are internal; start->root becomes the merged edge. + expect(only!.internalEdgeCount).toBe(9); + + expect(plan.mergedEdges).toHaveLength(1); + expect(plan.mergedEdges[0]!.mergedFrom).toEqual(['root']); + expect(nodes.filter((n) => !plan.foldOf.has(n.id)).map((n) => n.id)).toEqual(['start']); + }); + + it('never folds the root', () => { + const { nodes, edges } = fanFixture(20); + const plan = fold(nodes, edges, options(2)); + + expect(plan.foldOf.has('start')).toBe(false); + expect(plan.folds).toHaveLength(1); + }); + + it('holds an expanded root open and re-applies the rule one level down', () => { + // start -- root -- a -- a0..a4 + // '- b -- b0..b4 + const nodes: TestNode[] = [node('start'), node('root'), node('a'), node('b')]; + const edges: TestEdge[] = [link('start', 'root'), link('root', 'a'), link('root', 'b')]; + for (const parent of ['a', 'b']) { + for (let i = 0; i < 5; i++) { + nodes.push(node(`${parent}${i}`)); + edges.push(link(parent, `${parent}${i}`)); + } + } + + // Threshold 5: root (13 foldable) and each of a / b (6) are over it. + expect(fold(nodes, edges, options(5)).folds.map((f) => f.rootId)).toEqual(['root']); + + const expanded = fold(nodes, edges, options(5, ['root'])); + expect(expanded.folds.map((f) => f.rootId).sort()).toEqual(['a', 'b']); + expect(expanded.folds.every((f) => f.memberIds.length === 6)).toBe(true); + // root surfaced, and is flagged so it can be regrouped. + expect(expanded.foldOf.has('root')).toBe(false); + expect(expanded.heldOpen).toContain('root'); + }); + + it('does not hold open a branch root that is too small to regroup', () => { + const { nodes, edges } = fanFixture(3); + expect(fold(nodes, edges, options(10, ['root'])).heldOpen).toHaveLength(0); + }); + + it('keeps a satellite out of a fold and merges its member edges into one', () => { + const { nodes, edges } = fanFixture(9); + nodes.push(satellite('same_ip:1.2.3.4')); + for (const member of ['c0', 'c1', 'c2']) { + edges.push(match('same_ip:1.2.3.4', member)); + } + + const plan = fold(nodes, edges, options(9)); + + expect(plan.foldOf.has('same_ip:1.2.3.4')).toBe(false); + expect(plan.folds).toHaveLength(1); + + const satEdges = plan.mergedEdges.filter((e) => e.source === 'same_ip:1.2.3.4' || e.target === 'same_ip:1.2.3.4'); + expect(satEdges).toHaveLength(1); + expect(satEdges[0]!.allAssociative).toBe(true); + expect(satEdges[0]!.mergedFrom).toEqual(['c0', 'c1', 'c2']); + }); + + it('merges many member links to the same external node into one edge', () => { + const { nodes, edges } = fanFixture(9); + nodes.push(node('outsider')); + // outsider is reached from start directly, so it stays visible... + edges.push(link('start', 'outsider')); + // ...and five members link across to it. + for (let i = 0; i < 5; i++) { + edges.push(link(`c${i}`, 'outsider')); + } + + const plan = fold(nodes, edges, options(9)); + const crossing = plan.mergedEdges.filter((e) => e.source === 'outsider' || e.target === 'outsider'); + + // start->outsider has no folded endpoint, so it is not the plan's business. + expect(crossing).toHaveLength(1); + expect(crossing[0]!.mergedFrom).toHaveLength(5); + expect(crossing[0]!.allAssociative).toBe(false); + }); + + it('merges links between two folded branches into a single edge', () => { + const nodes: TestNode[] = [node('start'), node('a'), node('b')]; + const edges: TestEdge[] = [link('start', 'a'), link('start', 'b')]; + for (const parent of ['a', 'b']) { + for (let i = 0; i < 4; i++) { + nodes.push(node(`${parent}${i}`)); + edges.push(link(parent, `${parent}${i}`)); + } + } + edges.push(link('a0', 'b0'), link('a1', 'b1'), link('a2', 'b2')); + + // Each branch is 5 foldable nodes, one over the threshold of 4. + const plan = fold(nodes, edges, options(4)); + expect(plan.folds.map((f) => f.rootId).sort()).toEqual(['a', 'b']); + + const between = plan.mergedEdges.filter( + (e) => (e.source === 'a' && e.target === 'b') || (e.source === 'b' && e.target === 'a'), + ); + expect(between).toHaveLength(1); + // Six distinct member nodes are involved across the three merged links. + expect(between[0]!.mergedFrom).toHaveLength(6); + }); + + it('will not fold an island reachable only through a satellite', () => { + // Rewritten from the pre-unification behaviour, where folding walked every + // edge and this island DID collapse into a chip. One tree now: a satellite + // cannot hold a branch together, so this is not a branch. It stays on + // canvas and the layout gives it a pocket instead. + const nodes: TestNode[] = [node('start'), node('neighbor'), satellite('same_ip:9.9.9.9')]; + const edges: TestEdge[] = [link('start', 'neighbor'), match('same_ip:9.9.9.9', 'neighbor')]; + let prev = 'same_ip:9.9.9.9'; + for (let i = 0; i < 6; i++) { + nodes.push(node(`island${i}`)); + edges.push(match(prev, `island${i}`)); + prev = `island${i}`; + } + + const plan = fold(nodes, edges, options(5, ['neighbor'])); + + expect(plan.folds).toHaveLength(0); + expect(plan.mergedEdges).toHaveLength(0); + for (let i = 0; i < 6; i++) { + expect(plan.foldOf.has(`island${i}`)).toBe(false); + } + }); + + it('excludes satellites in the tree itself, not by filtering members later', () => { + // start -- root -- c0..c8, with a satellite hanging off c0. + const { nodes, edges } = fanFixture(9); + nodes.push(satellite('same_iban:DE00')); + edges.push(match('same_iban:DE00', 'c0')); + + const tree = buildSpanningTree(graph(nodes, edges, 'start'), classify); + expect(tree.satelliteIds).toEqual(['same_iban:DE00']); + expect(tree.memberIds).not.toContain('same_iban:DE00'); + // Absent from the tree entirely — no edge of it, not merely no member of it. + expect(tree.edges.some((e) => e.source === 'same_iban:DE00' || e.target === 'same_iban:DE00')).toBe(false); + + const only = fold(nodes, edges, options(9)).folds[0]!; + expect(only.memberIds).toHaveLength(10); + expect(only.memberIds).not.toContain('same_iban:DE00'); + }); +}); diff --git a/packages/ego-graph/src/fold/index.ts b/packages/ego-graph/src/fold/index.ts new file mode 100644 index 000000000..c9754425e Binary files /dev/null and b/packages/ego-graph/src/fold/index.ts differ diff --git a/packages/ego-graph/src/geometry/angles.ts b/packages/ego-graph/src/geometry/angles.ts new file mode 100644 index 000000000..c9111ff18 --- /dev/null +++ b/packages/ego-graph/src/geometry/angles.ts @@ -0,0 +1,107 @@ +import type { Point, RankDir } from '../types'; + +/** Slot angle: 12 o'clock, increasing clockwise (screen y-down). */ +export function slotAngle(slotIndex: number, slotCount: number): number { + return Math.PI / 2 + (2 * Math.PI * slotIndex) / slotCount; +} + +/** Normalize angle into (-π, π]. */ +export function normalizeAngle(theta: number): number { + let a = theta; + while (a <= -Math.PI) a += 2 * Math.PI; + while (a > Math.PI) a -= 2 * Math.PI; + return a; +} + +/** Smallest signed delta from `from` to `to` in (-π, π]. */ +export function angleDelta(from: number, to: number): number { + return normalizeAngle(to - from); +} + +/** Outward Dagre rankdir from angle (nearest cardinal axis). */ +export function rankdirFromAngle(theta: number): RankDir { + const cos = Math.cos(theta); + const sin = Math.sin(theta); + if (Math.abs(cos) >= Math.abs(sin)) { + return cos >= 0 ? 'LR' : 'RL'; + } + return sin >= 0 ? 'TB' : 'BT'; +} + +/** The axis a rankdir grows along, as an angle. */ +export function rankdirAxisAngle(rankdir: RankDir): number { + switch (rankdir) { + case 'LR': + return 0; + case 'TB': + return Math.PI / 2; + case 'RL': + return Math.PI; + case 'BT': + return -Math.PI / 2; + } +} + +export function rotateOffset(dx: number, dy: number, delta: number): Point { + const c = Math.cos(delta); + const s = Math.sin(delta); + return { x: dx * c - dy * s, y: dx * s + dy * c }; +} + +/** Sector center angles: full circle when `outboundTheta` is null, else outward hemisphere. */ +export function sectorAngles(sectorCount: number, outboundTheta: number | null): number[] { + if (sectorCount <= 0) return []; + if (outboundTheta == null) { + return Array.from({ length: sectorCount }, (_, i) => slotAngle(i, sectorCount)); + } + const span = Math.PI; + const start = outboundTheta - span / 2; + const width = span / sectorCount; + return Array.from({ length: sectorCount }, (_, i) => start + width * (i + 0.5)); +} + +/** + * Preferred pocket angle for a satellite: direction from the root center toward + * the centroid of its already-placed associative neighbours. + * + * Returns `null` when there are no placed anchors — the caller should fall back + * to {@link largestFreeGapAngle}. + */ +export function preferredSatelliteAngle(rootCenter: Point, placedNeighborCenters: Point[]): number | null { + if (placedNeighborCenters.length === 0) return null; + + let sx = 0; + let sy = 0; + for (const p of placedNeighborCenters) { + sx += p.x; + sy += p.y; + } + const cx = sx / placedNeighborCenters.length; + const cy = sy / placedNeighborCenters.length; + return Math.atan2(cy - rootCenter.y, cx - rootCenter.x); +} + +/** + * Angle in the middle of the largest gap between existing level-1 slot angles. + * Used when a satellite has no placed anchors. Falls back to east (0) when + * there are no slots to avoid. + */ +export function largestFreeGapAngle(ringThetas: number[]): number { + if (ringThetas.length === 0) return 0; + + const sorted = [...ringThetas].map(normalizeAngle).sort((a, b) => a - b); + let bestMid = sorted[0]!; + let bestGap = -Infinity; + + for (let i = 0; i < sorted.length; i++) { + const cur = sorted[i]!; + const next = i + 1 < sorted.length ? sorted[i + 1]! : sorted[0]! + 2 * Math.PI; + const gap = next - cur; + if (gap > bestGap) { + bestGap = gap; + bestMid = normalizeAngle(cur + gap / 2); + } + } + + return bestMid; +} diff --git a/packages/ego-graph/src/geometry/box.ts b/packages/ego-graph/src/geometry/box.ts new file mode 100644 index 000000000..554e97d75 --- /dev/null +++ b/packages/ego-graph/src/geometry/box.ts @@ -0,0 +1,19 @@ +import type { LayoutNode, Point } from '../types'; + +export function centerOf(node: LayoutNode, position: Point): Point { + return { x: position.x + node.width / 2, y: position.y + node.height / 2 }; +} + +export function topLeftFromCenter(center: Point, width: number, height: number): Point { + return { x: center.x - width / 2, y: center.y - height / 2 }; +} + +/** Half-extent of an axis-aligned box perpendicular to angle `theta`. */ +export function aabbHalfPerp(width: number, height: number, theta: number): number { + return (width / 2) * Math.abs(Math.sin(theta)) + (height / 2) * Math.abs(Math.cos(theta)); +} + +/** Half-extent of an axis-aligned box along angle `theta`. */ +export function aabbHalfRadial(width: number, height: number, theta: number): number { + return (width / 2) * Math.abs(Math.cos(theta)) + (height / 2) * Math.abs(Math.sin(theta)); +} diff --git a/packages/ego-graph/src/geometry/extents.ts b/packages/ego-graph/src/geometry/extents.ts new file mode 100644 index 000000000..271f40c23 --- /dev/null +++ b/packages/ego-graph/src/geometry/extents.ts @@ -0,0 +1,141 @@ +import type { LayoutNode, Point, RankDir, SpacingOptions } from '../types'; +import { aabbHalfPerp } from './box'; + +/** Just the two dials the extent math needs. */ +export type RadiusSpacing = Required>; + +/** + * Half-extent of a laid-out subtree perpendicular to the outward rank axis, + * measured from the root's center. Cardinal axes only — for an arbitrary ray + * use {@link lateralHalfExtentAtAngle}. + */ +export function lateralHalfExtent( + subtreeIds: string[], + positions: Map, + nodesById: Map, + rootId: string, + rankdir: RankDir, +): number { + const rootPos = positions.get(rootId); + const rootNode = nodesById.get(rootId); + if (!rootPos || !rootNode) return 0; + + const rootCenter = { + x: rootPos.x + rootNode.width / 2, + y: rootPos.y + rootNode.height / 2, + }; + + const horizontal = rankdir === 'LR' || rankdir === 'RL'; + let half = horizontal ? rootNode.height / 2 : rootNode.width / 2; + + for (const id of subtreeIds) { + const pos = positions.get(id); + const node = nodesById.get(id); + if (!pos || !node) continue; + const cx = pos.x + node.width / 2; + const cy = pos.y + node.height / 2; + if (horizontal) { + half = Math.max(half, Math.abs(cy - rootCenter.y) + node.height / 2); + } else { + half = Math.max(half, Math.abs(cx - rootCenter.x) + node.width / 2); + } + } + + return half; +} + +/** + * Half-extent of a laid-out subtree perpendicular to the ray at `theta`, + * measured from the root's center. + * + * Unlike {@link lateralHalfExtent} this does not snap to a cardinal axis, so it + * stays honest for placements that fan to arbitrary angles. + */ +export function lateralHalfExtentAtAngle( + subtreeIds: string[], + positions: Map, + nodesById: Map, + rootId: string, + theta: number, +): number { + const rootPos = positions.get(rootId); + const rootNode = nodesById.get(rootId); + if (!rootPos || !rootNode) return 0; + + const rootCenter = { + x: rootPos.x + rootNode.width / 2, + y: rootPos.y + rootNode.height / 2, + }; + // Unit vector perpendicular to the outbound ray. + const px = -Math.sin(theta); + const py = Math.cos(theta); + + let half = aabbHalfPerp(rootNode.width, rootNode.height, theta); + + for (const id of subtreeIds) { + const pos = positions.get(id); + const node = nodesById.get(id); + if (!pos || !node) continue; + const cx = pos.x + node.width / 2 - rootCenter.x; + const cy = pos.y + node.height / 2 - rootCenter.y; + const offset = Math.abs(cx * px + cy * py); + half = Math.max(half, offset + aabbHalfPerp(node.width, node.height, theta)); + } + + return half; +} + +/** Ring radius for `n` items spaced over a full circle (adjacent gap `2π/n`). */ +export function computeRingRadius(lateralHalves: number[], spacing: RadiusSpacing): number { + const n = lateralHalves.length; + if (n <= 1) return spacing.minRingRadius; + return computeArcRadius(lateralHalves, (2 * Math.PI) / n, true, spacing); +} + +/** + * Radius so adjacent lateral extents clear each other when placed with the + * given angular gap between neighbors. When `closed` is true the first and + * last items are also neighbors (full ring). + */ +export function computeArcRadius( + lateralHalves: number[], + adjacentGap: number, + closed: boolean, + spacing: RadiusSpacing, +): number { + const n = lateralHalves.length; + if (n <= 1) return spacing.minRingRadius; + + let r = spacing.minRingRadius; + const sinHalf = Math.sin(adjacentGap / 2); + if (sinHalf <= 1e-6) return r; + + const pairCount = closed ? n : n - 1; + for (let i = 0; i < pairCount; i++) { + const j = (i + 1) % n; + const needed = (lateralHalves[i]! + lateralHalves[j]! + spacing.ringPadding) / (2 * sinHalf); + r = Math.max(r, needed); + } + return r; +} + +/** + * Radius of the outer satellite pocket ring: clears the axis-aligned boxes of + * everything already placed, measured from the root center, plus padding. + */ +export function computeSatellitePocketRadius( + placedBoxes: Array<{ center: Point; width: number; height: number }>, + rootCenter: Point, + spacing: RadiusSpacing, +): number { + let r = spacing.minRingRadius; + for (const { center, width, height } of placedBoxes) { + const dx = Math.abs(center.x - rootCenter.x) + width / 2; + const dy = Math.abs(center.y - rootCenter.y) + height / 2; + // Conservative clearance: distance to farthest bbox corner from the root. + const dist = Math.hypot(center.x - rootCenter.x, center.y - rootCenter.y); + const halfDiag = Math.hypot(width / 2, height / 2); + r = Math.max(r, dist + halfDiag + spacing.ringPadding, dx + spacing.ringPadding, dy + spacing.ringPadding); + } + return r; +} diff --git a/packages/ego-graph/src/geometry/geometry.spec.ts b/packages/ego-graph/src/geometry/geometry.spec.ts new file mode 100644 index 000000000..15bc68873 --- /dev/null +++ b/packages/ego-graph/src/geometry/geometry.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { claimUnplacedComponent, fanSatelliteAngles } from '../layouts/pockets'; +import { DEFAULT_SPACING } from '../options'; +import { largestFreeGapAngle, normalizeAngle, preferredSatelliteAngle } from './angles'; + +describe('preferredSatelliteAngle', () => { + it('returns null when there are no placed anchors', () => { + expect(preferredSatelliteAngle({ x: 0, y: 0 }, [])).toBeNull(); + }); + + it('points toward the centroid of placed neighbours', () => { + const theta = preferredSatelliteAngle({ x: 0, y: 0 }, [ + { x: 100, y: 0 }, + { x: 200, y: 0 }, + ]); + expect(theta).not.toBeNull(); + expect(theta!).toBeCloseTo(0, 5); + }); +}); + +describe('largestFreeGapAngle', () => { + it('defaults to east when there are no ring slots', () => { + expect(largestFreeGapAngle([])).toBe(0); + }); + + it('returns the midpoint of the largest gap between ring angles', () => { + // Clustered on the right; the largest open sector is the left half-plane. + const mid = largestFreeGapAngle([-0.2, 0, 0.2]); + // Wrap gap from 0.2 → (-0.2 + 2π); midpoint ≈ π. + expect(Math.abs(normalizeAngle(mid - Math.PI))).toBeLessThan(0.05); + }); +}); + +describe('fanSatelliteAngles', () => { + it('keeps a single satellite on its preferred angle', () => { + const angles = fanSatelliteAngles( + [{ id: 'a', preferredTheta: Math.PI / 2, lateralHalf: 40 }], + 300, + DEFAULT_SPACING, + ); + expect(angles.get('a')).toBeCloseTo(Math.PI / 2, 5); + }); + + it('spreads two satellites that want the same side', () => { + const angles = fanSatelliteAngles( + [ + { id: 'a', preferredTheta: 0, lateralHalf: 80 }, + { id: 'b', preferredTheta: 0.05, lateralHalf: 80 }, + ], + 300, + DEFAULT_SPACING, + ); + expect(Math.abs(normalizeAngle(angles.get('b')! - angles.get('a')!))).toBeGreaterThan(0.1); + }); +}); + +describe('claimUnplacedComponent', () => { + it('claims the full unplaced component and removes ids from the set', () => { + const unplaced = new Set(['sat', 'only_a', 'only_b', 'other']); + const adj = new Map([ + ['sat', ['only_a']], + ['only_a', ['sat', 'only_b']], + ['only_b', ['only_a']], + ['other', []], + ]); + + expect(claimUnplacedComponent('sat', unplaced, adj)).toEqual(['sat', 'only_a', 'only_b']); + expect(unplaced.has('other')).toBe(true); + expect(unplaced.has('sat')).toBe(false); + }); + + it('first claim wins — a second satellite cannot re-claim nodes', () => { + const unplaced = new Set(['s1', 's2', 'shared']); + const adj = new Map([ + ['s1', ['shared']], + ['s2', ['shared']], + ['shared', ['s1', 's2']], + ]); + const skip = new Set(['s1', 's2']); + + expect(claimUnplacedComponent('s1', unplaced, adj, skip)).toEqual(['s1', 'shared']); + expect(claimUnplacedComponent('s2', unplaced, adj, skip)).toEqual(['s2']); + }); +}); diff --git a/packages/ego-graph/src/geometry/order.ts b/packages/ego-graph/src/geometry/order.ts new file mode 100644 index 000000000..232af344b --- /dev/null +++ b/packages/ego-graph/src/geometry/order.ts @@ -0,0 +1,70 @@ +/** + * Heaviest-first greedy: each item goes in the open slot whose nearest + * already-placed neighbors have the smallest combined weight. + */ +export function greedySlotOrder(items: Array<{ id: string; weight: number }>): string[] { + const n = items.length; + if (n === 0) return []; + + const slots: Array = Array.from({ length: n }, () => null); + const slotWeight = Array.from({ length: n }, () => 0); + const sorted = [...items].sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id)); + + for (const item of sorted) { + let bestSlot = -1; + let bestScore = Infinity; + + for (let s = 0; s < n; s++) { + if (slots[s] !== null) continue; + + let leftW = 0; + let leftDist = 0; + let rightW = 0; + let rightDist = 0; + for (let d = 1; d < n; d++) { + const left = (s - d + n) % n; + if (slots[left] !== null) { + leftW = slotWeight[left]!; + leftDist = d; + break; + } + } + for (let d = 1; d < n; d++) { + const right = (s + d) % n; + if (slots[right] !== null) { + rightW = slotWeight[right]!; + rightDist = d; + break; + } + } + + // Distance-weighted so opposite empty slots beat adjacent ones when only + // one neighbor is placed (plain sum would tie and pick the next index). + const score = (leftDist > 0 ? leftW / leftDist : 0) + (rightDist > 0 ? rightW / rightDist : 0); + if (score < bestScore || (score === bestScore && (bestSlot < 0 || s < bestSlot))) { + bestScore = score; + bestSlot = s; + } + } + + slots[bestSlot] = item.id; + slotWeight[bestSlot] = item.weight; + } + + return slots as string[]; +} + +/** + * Subtree size including `id` itself. + * + * `weightOf` lets a single node stand for more than one — return the member + * count for a node representing a folded branch, so the ring reserves the room + * its contents would have needed. + */ +export function descendantCount(children: Map, id: string, weightOf: (id: string) => number): number { + let count = weightOf(id); + for (const child of children.get(id) ?? []) { + count += descendantCount(children, child, weightOf); + } + return count; +} diff --git a/packages/ego-graph/src/index.ts b/packages/ego-graph/src/index.ts new file mode 100644 index 000000000..6ce2b3207 --- /dev/null +++ b/packages/ego-graph/src/index.ts @@ -0,0 +1,16 @@ +export { polarPetal } from './layouts/polar-petal'; +export { radialDagre } from './layouts/radial-dagre'; +export { sectoredDagre } from './layouts/sectored-dagre'; +export { DEFAULT_SPACING } from './options'; +export { buildSpanningTree, reachableNodeIds, type SpanningTree } from './tree'; +export type { + ClassifyOptions, + EgoGraph, + LayoutEdge, + LayoutNode, + LayoutOptions, + Point, + Positions, + RankDir, + SpacingOptions, +} from './types'; diff --git a/packages/ego-graph/src/layouts/dagre-subtree.ts b/packages/ego-graph/src/layouts/dagre-subtree.ts new file mode 100644 index 000000000..a40afa636 --- /dev/null +++ b/packages/ego-graph/src/layouts/dagre-subtree.ts @@ -0,0 +1,76 @@ +import Dagre from '@dagrejs/dagre'; +import { rankdirFromAngle } from '../geometry/angles'; +import { topLeftFromCenter } from '../geometry/box'; +import { lateralHalfExtent } from '../geometry/extents'; +import type { LayoutEdge, LayoutNode, Point, RankDir, SpacingOptions } from '../types'; +import { placeSubtreeAt } from './place'; +import type { PocketStrategy } from './pocket-strategy'; + +/** + * The one place `@dagrejs/dagre` is imported. Anything that does not reach this + * module does not pull Dagre into the bundle — which is the whole reason + * `polarPetal` can be used dependency-free. + */ +export function layoutSubtreeLocal( + subtreeIds: string[], + treeEdges: LayoutEdge[], + nodesById: Map, + rankdir: RankDir, + spacing: Required>, +): Map { + const idSet = new Set(subtreeIds); + const positions = new Map(); + + if (subtreeIds.length === 1) { + const id = subtreeIds[0]!; + const node = nodesById.get(id); + if (!node) return positions; + positions.set(id, topLeftFromCenter({ x: 0, y: 0 }, node.width, node.height)); + return positions; + } + + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + g.setGraph({ + rankdir, + nodesep: spacing.nodeSep, + ranksep: spacing.rankSep, + }); + + for (const id of subtreeIds) { + const node = nodesById.get(id); + if (!node) continue; + g.setNode(id, { width: node.width, height: node.height }); + } + + for (const edge of treeEdges) { + if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue; + g.setEdge(edge.source, edge.target); + } + + Dagre.layout(g); + + for (const id of subtreeIds) { + const positioned = g.node(id); + const node = nodesById.get(id); + if (!positioned || !node) continue; + positions.set(id, { + x: positioned.x - node.width / 2, + y: positioned.y - node.height / 2, + }); + } + + return positions; +} + +/** A Dagre subtree, translated so the satellite sits on the outer pocket ring. */ +export const dagrePocketStrategy: PocketStrategy = { + measureLateralHalf({ islandIds, treeEdges, rootId, preferredTheta, nodesById, spacing }) { + const rankdir = rankdirFromAngle(preferredTheta); + const localPositions = layoutSubtreeLocal(islandIds, treeEdges, nodesById, rankdir, spacing); + return lateralHalfExtent(islandIds, localPositions, nodesById, rootId, rankdir); + }, + place({ islandIds, treeEdges, rootId, targetCenter, theta, nodesById, positionById, spacing }) { + const localPositions = layoutSubtreeLocal(islandIds, treeEdges, nodesById, rankdirFromAngle(theta), spacing); + placeSubtreeAt(islandIds, localPositions, nodesById, rootId, targetCenter, positionById); + }, +}; diff --git a/packages/ego-graph/src/layouts/layouts.spec.ts b/packages/ego-graph/src/layouts/layouts.spec.ts new file mode 100644 index 000000000..442730030 --- /dev/null +++ b/packages/ego-graph/src/layouts/layouts.spec.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeAngle, slotAngle } from '../geometry/angles'; +import { + centerOf, + classify, + distance, + graph, + link, + match, + NODE_SIZE, + node, + satellite, + type TestNode, +} from '../test-support'; +import { polarPetal } from './polar-petal'; +import { radialDagre } from './radial-dagre'; +import { sectoredDagre } from './sectored-dagre'; + +const LAYOUTS = [ + { name: 'radialDagre', run: radialDagre }, + { name: 'sectoredDagre', run: sectoredDagre }, + { name: 'polarPetal', run: polarPetal }, +] as const; + +describe('radialDagre', () => { + it('keeps a satellite out of the structural spanning tree', () => { + const nodes = [node('start'), node('chiara'), node('other'), node('company'), satellite('same_ip:1')]; + const edges = [ + link('start', 'chiara'), + link('start', 'other'), + link('start', 'company'), + match('same_ip:1', 'chiara'), + match('same_ip:1', 'company'), + ]; + + const positions = radialDagre(graph(nodes, edges, 'start'), classify); + const start = centerOf(positions, nodes, 'start'); + const sat = centerOf(positions, nodes, 'same_ip:1'); + const chiara = centerOf(positions, nodes, 'chiara'); + + expect(start.x).toBeCloseTo(0, 0); + expect(start.y).toBeCloseTo(0, 0); + + // The satellite sits outside the ring, farther out than a level-1 node. + expect(distance(sat, start)).toBeGreaterThan(distance(chiara, start)); + + // Not parked as a deep branch directly above the root (the legacy bug). + expect(Math.abs(sat.x)).toBeGreaterThan(40); + }); + + it('places an associative-only chain as an island under the claiming satellite', () => { + const nodes = [node('start'), node('anchor'), node('orphan_a'), node('orphan_b'), satellite('ip')]; + const edges = [ + link('start', 'anchor'), + match('ip', 'anchor'), + match('ip', 'orphan_a'), + link('orphan_a', 'orphan_b'), + ]; + + const positions = radialDagre(graph(nodes, edges, 'start'), classify); + for (const id of ['orphan_a', 'orphan_b', 'ip']) { + expect(positions.get(id)).toBeDefined(); + } + + const ip = centerOf(positions, nodes, 'ip'); + const orphanA = centerOf(positions, nodes, 'orphan_a'); + // The orphan stays with its island, not with the root. + expect(distance(orphanA, ip)).toBeLessThan(distance(orphanA)); + }); + + it('fans two satellites that want the same side', () => { + const nodes = [node('start'), node('east'), satellite('ip_a'), satellite('ip_b')]; + const edges = [link('start', 'east'), match('ip_a', 'east'), match('ip_b', 'east')]; + + const positions = radialDagre(graph(nodes, edges, 'start'), classify); + const a = centerOf(positions, nodes, 'ip_a'); + const b = centerOf(positions, nodes, 'ip_b'); + + expect(Math.abs(normalizeAngle(Math.atan2(a.y, a.x) - Math.atan2(b.y, b.x)))).toBeGreaterThan(0.05); + }); + + it('aims a satellite pocket at the centroid of its placed neighbours', () => { + const nodes = [ + node('user_0001'), + node('user_0002'), + node('user_0003'), + node('user_0004'), + node('user_0012'), + node('comp_0001'), + satellite('same_ip'), + ]; + const edges = [ + link('user_0001', 'user_0002'), + link('user_0001', 'user_0003'), + link('user_0001', 'user_0012'), + link('user_0001', 'comp_0001'), + link('user_0004', 'comp_0001'), + match('same_ip', 'user_0003'), + match('same_ip', 'comp_0001'), + match('same_ip', 'user_0004'), + ]; + + const positions = radialDagre(graph(nodes, edges, 'user_0001'), classify); + const sat = centerOf(positions, nodes, 'same_ip'); + const chiara = centerOf(positions, nodes, 'user_0003'); + const company = centerOf(positions, nodes, 'comp_0001'); + const fourth = centerOf(positions, nodes, 'user_0004'); + + expect(distance(centerOf(positions, nodes, 'user_0001'))).toBeLessThan(1); + expect(distance(sat)).toBeGreaterThan(distance(chiara)); + + const centroid = { + x: (chiara.x + company.x + fourth.x) / 3, + y: (chiara.y + company.y + fourth.y) / 3, + }; + const preferred = Math.atan2(centroid.y, centroid.x); + const actual = Math.atan2(sat.y, sat.x); + expect(Math.abs(normalizeAngle(actual - preferred))).toBeLessThan(Math.PI / 2); + }); + + it('uses the largest free ring gap when a satellite has no placed anchors', () => { + const nodes = [node('start'), node('n1'), node('n2'), node('n3'), satellite('orphan_ip')]; + const edges = [link('start', 'n1'), link('start', 'n2'), link('start', 'n3')]; + + const positions = radialDagre(graph(nodes, edges, 'start'), classify); + const c = centerOf(positions, nodes, 'orphan_ip'); + + // Placed on the outer arc, not left at the origin on top of the root. + expect(distance(c)).toBeGreaterThan(100); + + // And in a gap, not on top of a level-1 slot angle. + const satAngle = Math.atan2(c.y, c.x); + for (const i of [0, 1, 2]) { + expect(Math.abs(normalizeAngle(satAngle - slotAngle(i, 3)))).toBeGreaterThan(0.2); + } + }); +}); + +describe.each(LAYOUTS)('$name', ({ run }) => { + it('returns nothing for an empty graph', () => { + expect(run(graph([], [], 'nope')).size).toBe(0); + }); + + it('centres the root at the origin', () => { + const nodes = [node('root'), node('a'), node('b')]; + const positions = run(graph(nodes, [link('root', 'a'), link('root', 'b')], 'root'), classify); + expect(distance(centerOf(positions, nodes, 'root'))).toBeLessThan(1e-6); + }); + + it('falls back to the first node when the requested root is absent', () => { + const nodes = [node('a'), node('b')]; + const positions = run(graph(nodes, [link('a', 'b')], 'ghost'), classify); + expect(distance(centerOf(positions, nodes, 'a'))).toBeLessThan(1e-6); + }); + + it('places every node exactly once, with no two overlapping', () => { + const nodes = [node('root'), node('a'), node('b'), node('c'), node('d'), node('e'), satellite('sat')]; + const edges = [ + link('root', 'a'), + link('root', 'b'), + link('root', 'c'), + link('a', 'd'), + link('b', 'e'), + match('sat', 'd'), + ]; + + const positions = run(graph(nodes, edges, 'root'), classify); + expect(positions.size).toBe(nodes.length); + expectNoOverlaps(nodes, positions); + }); + + it('gives an orphan component a pocket even with no satellite to claim it', () => { + // 'far_a'/'far_b' hang off the root by an associative edge only, so the + // structural tree cannot reach them and no satellite exists to adopt them. + const nodes = [node('root'), node('a'), node('far_a'), node('far_b')]; + const edges = [link('root', 'a'), match('root', 'far_a'), link('far_a', 'far_b')]; + + const positions = run(graph(nodes, edges, 'root'), classify); + expect(positions.size).toBe(nodes.length); + + // Placed out on the arc, not stacked at the origin under the root. + expect(distance(centerOf(positions, nodes, 'far_a'))).toBeGreaterThan(100); + expect(distance(centerOf(positions, nodes, 'far_b'))).toBeGreaterThan(100); + expectNoOverlaps(nodes, positions); + }); + + it('honours a getWeight override when ordering ring slots', () => { + // Six siblings: equal weights order alphabetically, so 'zeta' lands last. + // Declaring it heavy must promote it to the first slot instead. Six rather + // than three so sectoredDagre is over its bushy threshold and actually + // sectors them — below it, it uses one shallow pocket and ignores weight. + const kids = ['alpha', 'beta', 'delta', 'epsilon', 'gamma', 'zeta']; + const nodes = [node('root'), ...kids.map((id) => node(id))]; + const edges = kids.map((id) => link('root', id)); + + const base = run(graph(nodes, edges, 'root'), classify); + const weighted = run(graph(nodes, edges, 'root'), { + ...classify, + getWeight: (n: TestNode) => (n.id === 'zeta' ? 20 : 1), + }); + + const before = centerOf(base, nodes, 'zeta'); + const after = centerOf(weighted, nodes, 'zeta'); + expect(distance(before, after)).toBeGreaterThan(1); + }); + + it('treats every edge as structural and every node as ordinary by default', () => { + const nodes = [node('root'), node('a'), node('b')]; + // No predicates: the associative edge must still build the tree. + const positions = run(graph(nodes, [match('root', 'a'), match('a', 'b')], 'root')); + expect(positions.size).toBe(3); + expect(distance(centerOf(positions, nodes, 'b'))).toBeGreaterThan(0); + }); +}); + +describe('polarPetal pocket sizing', () => { + it('keeps multi-level islands from overlapping', () => { + // Two satellites wanting the same side, each owning a two-level island. + // Measuring only the largest single node under-reports how far the petals + // spread, and the pockets collide. + const nodes = [ + node('root'), + node('east'), + satellite('s1'), + satellite('s2'), + node('a1'), + node('a2'), + node('a3'), + node('b1'), + node('b2'), + node('b3'), + ]; + const edges = [ + link('root', 'east'), + match('s1', 'east'), + match('s2', 'east'), + match('s1', 'a1'), + link('a1', 'a2'), + link('a1', 'a3'), + match('s2', 'b1'), + link('b1', 'b2'), + link('b1', 'b3'), + ]; + + const positions = polarPetal(graph(nodes, edges, 'root'), classify); + expect(positions.size).toBe(nodes.length); + expectNoOverlaps(nodes, positions); + }); +}); + +function expectNoOverlaps(nodes: TestNode[], positions: Map): void { + const placed = nodes.map((n) => ({ id: n.id, pos: positions.get(n.id)!, node: n })).filter((p) => p.pos); + + for (let i = 0; i < placed.length; i++) { + for (let j = i + 1; j < placed.length; j++) { + const a = placed[i]!; + const b = placed[j]!; + const overlapX = Math.abs(a.pos.x - b.pos.x) < NODE_SIZE.width; + const overlapY = Math.abs(a.pos.y - b.pos.y) < NODE_SIZE.height; + if (overlapX && overlapY) { + throw new Error(`${a.id} and ${b.id} overlap at ${JSON.stringify(a.pos)} / ${JSON.stringify(b.pos)}`); + } + } + } +} diff --git a/packages/ego-graph/src/layouts/place.ts b/packages/ego-graph/src/layouts/place.ts new file mode 100644 index 000000000..834de356e --- /dev/null +++ b/packages/ego-graph/src/layouts/place.ts @@ -0,0 +1,28 @@ +import type { LayoutNode, Point } from '../types'; + +/** Translate a locally-laid-out subtree so `rootId`'s center lands on `targetCenter`. */ +export function placeSubtreeAt( + subtreeIds: string[], + localPositions: Map, + nodesById: Map, + rootId: string, + targetCenter: Point, + positionById: Map, +): void { + const localRoot = localPositions.get(rootId); + const rootNode = nodesById.get(rootId); + if (!localRoot || !rootNode) return; + + const localRootCenter = { + x: localRoot.x + rootNode.width / 2, + y: localRoot.y + rootNode.height / 2, + }; + const dx = targetCenter.x - localRootCenter.x; + const dy = targetCenter.y - localRootCenter.y; + + for (const nodeId of subtreeIds) { + const local = localPositions.get(nodeId); + if (!local) continue; + positionById.set(nodeId, { x: local.x + dx, y: local.y + dy }); + } +} diff --git a/packages/ego-graph/src/layouts/pocket-strategy.ts b/packages/ego-graph/src/layouts/pocket-strategy.ts new file mode 100644 index 000000000..3d42f5301 --- /dev/null +++ b/packages/ego-graph/src/layouts/pocket-strategy.ts @@ -0,0 +1,23 @@ +import type { LayoutEdge, LayoutNode, Point, SpacingOptions } from '../types'; + +export type PocketContext = { + /** Every id in this island, including `rootId`. */ + islandIds: string[]; + /** BFS tree edges within the island, rooted at `rootId`. */ + treeEdges: LayoutEdge[]; + rootId: string; + nodesById: Map; + spacing: Required; + weightOf: (id: string) => number; +}; + +/** + * How a satellite's island is sized and drawn in its pocket. + * + * Two calls, deliberately separate: the caller measures every island first to + * pick a shared pocket radius and fan the angles, and only then places. + */ +export type PocketStrategy = { + measureLateralHalf: (args: PocketContext & { preferredTheta: number }) => number; + place: (args: PocketContext & { targetCenter: Point; theta: number; positionById: Map }) => void; +}; diff --git a/packages/ego-graph/src/layouts/pockets.ts b/packages/ego-graph/src/layouts/pockets.ts new file mode 100644 index 000000000..47e47de98 --- /dev/null +++ b/packages/ego-graph/src/layouts/pockets.ts @@ -0,0 +1,245 @@ +import { angleDelta, largestFreeGapAngle, normalizeAngle, preferredSatelliteAngle } from '../geometry/angles'; +import { centerOf } from '../geometry/box'; +import { computeSatellitePocketRadius } from '../geometry/extents'; +import type { ResolvedOptions } from '../options'; +import { bfsTreeEdges, buildUndirectedAdjacency, type SpanningTree } from '../tree'; +import type { EgoGraph, LayoutEdge, LayoutNode, Point, SpacingOptions } from '../types'; +import type { PocketStrategy } from './pocket-strategy'; + +/** + * Claim the still-unplaced connected component reachable from `rootId` via + * `adj`, removing claimed ids from `unplaced`. First caller wins, so the + * caller is responsible for iterating roots in a stable order. + * + * `skipIds` (typically the other satellites) are never entered — otherwise a + * shared neighbour would let the first satellite swallow its siblings. + * + * Returns claimed ids including `rootId`, in BFS order. + */ +export function claimUnplacedComponent( + rootId: string, + unplaced: Set, + adj: Map, + skipIds: Set = new Set(), +): string[] { + if (!unplaced.has(rootId)) return []; + + const claimed: string[] = []; + const queue = [rootId]; + unplaced.delete(rootId); + + while (queue.length > 0) { + const cur = queue.shift()!; + claimed.push(cur); + for (const nxt of adj.get(cur) ?? []) { + if (!unplaced.has(nxt)) continue; + // Other satellites stay for their own claim pass. + if (skipIds.has(nxt) && nxt !== rootId) continue; + unplaced.delete(nxt); + queue.push(nxt); + } + } + + return claimed; +} + +/** + * Fan satellites that want the same side onto an outer arc. + * + * 1. Sort by preferred angle + * 2. Cluster consecutive items within `satelliteGap` + * 3. Within each cluster, spread around the circular mean with separation + * driven by lateral half-extents at `radius` — the same geometric idea as + * the main ring radius computation + */ +export function fanSatelliteAngles( + items: Array<{ id: string; preferredTheta: number; lateralHalf: number }>, + radius: number, + spacing: Required>, +): Map { + const result = new Map(); + if (items.length === 0) return result; + if (items.length === 1) { + result.set(items[0]!.id, normalizeAngle(items[0]!.preferredTheta)); + return result; + } + + const sorted = [...items].sort( + (a, b) => normalizeAngle(a.preferredTheta) - normalizeAngle(b.preferredTheta) || a.id.localeCompare(b.id), + ); + + type Cluster = typeof sorted; + const clusters: Cluster[] = []; + let current: Cluster = [sorted[0]!]; + + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]!; + const item = sorted[i]!; + const gap = Math.abs(angleDelta(prev.preferredTheta, item.preferredTheta)); + if (gap > spacing.satelliteGap) { + clusters.push(current); + current = [item]; + } else { + current.push(item); + } + } + clusters.push(current); + + for (const cluster of clusters) { + if (cluster.length === 1) { + result.set(cluster[0]!.id, normalizeAngle(cluster[0]!.preferredTheta)); + continue; + } + + // Circular mean of preferred angles (average of unit vectors). + let ux = 0; + let uy = 0; + for (const item of cluster) { + ux += Math.cos(item.preferredTheta); + uy += Math.sin(item.preferredTheta); + } + const mean = Math.atan2(uy, ux); + + // Adjacent separation from lateral extents; clamp to a readable minimum. + const separations: number[] = []; + for (let i = 0; i < cluster.length - 1; i++) { + const needed = + radius > 1e-6 + ? (cluster[i]!.lateralHalf + cluster[i + 1]!.lateralHalf + spacing.ringPadding) / radius + : spacing.minSatelliteGap; + separations.push(Math.max(spacing.minSatelliteGap, needed)); + } + const totalSpan = separations.reduce((a, b) => a + b, 0); + + let cursor = mean - totalSpan / 2; + result.set(cluster[0]!.id, normalizeAngle(cursor)); + for (let i = 0; i < separations.length; i++) { + cursor += separations[i]!; + result.set(cluster[i + 1]!.id, normalizeAngle(cursor)); + } + } + + return result; +} + +/** + * Place everything the spanning tree could not reach, in pockets on an outer + * arc around the already-placed core. Mutates `positionById`. + * + * Two kinds of island get a pocket: + * - one per satellite, rooted at the satellite, claimed in sorted id order; + * - one per leftover component, rooted at its smallest id. + * + * The second kind matters more than it looks. Without it, a graph with no + * satellites at all — or one with components no satellite happens to touch — + * silently leaves those nodes at the origin, stacked on top of each other. + */ +export function layoutSatellitePockets(args: { + graph: EgoGraph; + tree: SpanningTree; + nodesById: Map; + positionById: Map; + rootCenter: Point; + ringThetas: number[]; + strategy: PocketStrategy; + options: ResolvedOptions; +}): void { + const { graph, tree, nodesById, positionById, rootCenter, ringThetas, strategy, options } = args; + + const allIds = graph.nodes.map((n) => n.id); + const unplaced = new Set(allIds.filter((id) => !positionById.has(id))); + if (unplaced.size === 0) return; + + const weightOf = (id: string): number => { + const node = nodesById.get(id); + return node ? options.getWeight(node) : 1; + }; + + // Pockets are about connectivity, not hierarchy, so this walks every edge — + // associative ones included. That is the point: an island hangs off its + // satellite precisely by the edges the tree refused. + const fullAdj = buildUndirectedAdjacency(allIds, graph.edges); + const satelliteIdSet = new Set(tree.satelliteIds); + + const islands: Array<{ rootId: string; ids: string[]; anchorIds: string[] }> = []; + + for (const satelliteId of tree.satelliteIds) { + if (!unplaced.has(satelliteId)) continue; + const ids = claimUnplacedComponent(satelliteId, unplaced, fullAdj, satelliteIdSet); + if (ids.length === 0) continue; + // A satellite points at its own neighbours. + islands.push({ rootId: satelliteId, ids, anchorIds: [satelliteId] }); + } + + // Whatever no satellite claimed. Sorted so the choice of root is deterministic. + for (const orphanId of [...unplaced].sort((a, b) => a.localeCompare(b))) { + if (!unplaced.has(orphanId)) continue; + const ids = claimUnplacedComponent(orphanId, unplaced, fullAdj); + if (ids.length === 0) continue; + // An orphan root is arbitrary, so aim the pocket using the whole component. + islands.push({ rootId: orphanId, ids, anchorIds: ids }); + } + + if (islands.length === 0) return; + + const placedBoxes: Array<{ center: Point; width: number; height: number }> = []; + for (const id of tree.memberIds) { + const pos = positionById.get(id); + const node = nodesById.get(id); + if (!pos || !node) continue; + placedBoxes.push({ center: centerOf(node, pos), width: node.width, height: node.height }); + } + const pocketRadius = computeSatellitePocketRadius(placedBoxes, rootCenter, options); + + const prepared = islands.map(({ rootId, ids, anchorIds }) => { + const placedNeighborCenters: Point[] = []; + for (const anchorId of anchorIds) { + for (const neighborId of fullAdj.get(anchorId) ?? []) { + const pos = positionById.get(neighborId); + const node = nodesById.get(neighborId); + if (!pos || !node) continue; + placedNeighborCenters.push(centerOf(node, pos)); + } + } + + const preferredTheta = + preferredSatelliteAngle(rootCenter, placedNeighborCenters) ?? largestFreeGapAngle(ringThetas); + + const treeEdges = bfsTreeEdges(rootId, fullAdj, new Set(ids)); + const lateralHalf = strategy.measureLateralHalf({ + islandIds: ids, + treeEdges, + rootId, + preferredTheta, + nodesById, + spacing: options, + weightOf, + }); + + return { id: rootId, islandIds: ids, treeEdges, preferredTheta, lateralHalf }; + }); + + const finalAngles = fanSatelliteAngles( + prepared.map((p) => ({ id: p.id, preferredTheta: p.preferredTheta, lateralHalf: p.lateralHalf })), + pocketRadius, + options, + ); + + for (const prep of prepared) { + const theta = finalAngles.get(prep.id) ?? prep.preferredTheta; + strategy.place({ + islandIds: prep.islandIds, + treeEdges: prep.treeEdges, + rootId: prep.id, + targetCenter: { + x: rootCenter.x + pocketRadius * Math.cos(theta), + y: rootCenter.y + pocketRadius * Math.sin(theta), + }, + theta, + nodesById, + positionById, + spacing: options, + weightOf, + }); + } +} diff --git a/packages/ego-graph/src/layouts/polar-petal.ts b/packages/ego-graph/src/layouts/polar-petal.ts new file mode 100644 index 000000000..01198901f --- /dev/null +++ b/packages/ego-graph/src/layouts/polar-petal.ts @@ -0,0 +1,62 @@ +import { topLeftFromCenter } from '../geometry/box'; +import { resolveOptions } from '../options'; +import { buildSpanningTree } from '../tree'; +import type { EgoGraph, LayoutEdge, LayoutNode, LayoutOptions, Positions } from '../types'; +import { layoutSatellitePockets } from './pockets'; +import { layoutPolarTree, polarPocketStrategy } from './polar-subtree'; + +/** + * Pure polar "flower petal" placement, all the way down: every node spreads its + * children over a hemisphere centered on the ray it arrived along. + * + * The only layout here with **no dependencies** — nothing in its import graph + * reaches `@dagrejs/dagre`. Prefers breadth over Dagre's rank discipline, so it + * suits shallow bushy graphs better than deep ones. + */ +export function polarPetal( + graph: EgoGraph, + options: LayoutOptions = {}, +): Positions { + const positionById: Positions = new Map(); + if (graph.nodes.length === 0) return positionById; + + const resolved = resolveOptions(options); + const nodesById = new Map(graph.nodes.map((n) => [n.id, n])); + const tree = buildSpanningTree(graph, resolved); + if (!tree.root) return positionById; + + const weightOf = (id: string): number => { + const node = nodesById.get(id); + return node ? resolved.getWeight(node) : 1; + }; + + const rootNode = nodesById.get(tree.root)!; + const rootCenter = { x: 0, y: 0 }; + positionById.set(tree.root, topLeftFromCenter(rootCenter, rootNode.width, rootNode.height)); + + const ringThetas: number[] = []; + layoutPolarTree({ + nodeId: tree.root, + outboundTheta: null, + children: tree.children, + nodesById, + positionById, + ringThetas, + isRoot: true, + spacing: resolved, + weightOf, + }); + + layoutSatellitePockets({ + graph, + tree, + nodesById, + positionById, + rootCenter, + ringThetas, + strategy: polarPocketStrategy, + options: resolved, + }); + + return positionById; +} diff --git a/packages/ego-graph/src/layouts/polar-subtree.ts b/packages/ego-graph/src/layouts/polar-subtree.ts new file mode 100644 index 000000000..68fff4502 --- /dev/null +++ b/packages/ego-graph/src/layouts/polar-subtree.ts @@ -0,0 +1,149 @@ +import { sectorAngles } from '../geometry/angles'; +import { aabbHalfPerp, aabbHalfRadial, centerOf, topLeftFromCenter } from '../geometry/box'; +import { computeArcRadius, computeRingRadius, lateralHalfExtentAtAngle } from '../geometry/extents'; +import { descendantCount, greedySlotOrder } from '../geometry/order'; +import { buildChildrenMap } from '../tree'; +import type { LayoutNode, Point, SpacingOptions } from '../types'; +import type { PocketStrategy } from './pocket-strategy'; + +export type PolarTreeArgs = { + nodeId: string; + /** `null` at the root, which then spreads its children over the full circle. */ + outboundTheta: number | null; + children: Map; + nodesById: Map; + positionById: Map; + /** Collects the angles of the root's own children, for satellite gap-finding. */ + ringThetas: number[]; + isRoot: boolean; + spacing: Required; + weightOf: (id: string) => number; +}; + +/** + * Pure polar flower-petal placement: 360° at the root, else a 180° hemisphere + * centered on the outbound ray. No Dagre. + */ +export function layoutPolarTree(args: PolarTreeArgs): void { + const { nodeId, outboundTheta, children, nodesById, positionById, ringThetas, isRoot, spacing, weightOf } = args; + const kidIds = children.get(nodeId) ?? []; + const n = kidIds.length; + if (n === 0) return; + + const parentPos = positionById.get(nodeId); + const parentNode = nodesById.get(nodeId); + if (!parentPos || !parentNode) return; + + const parentCenter = centerOf(parentNode, parentPos); + + const weighted = kidIds.map((id) => ({ + id, + weight: descendantCount(children, id, weightOf), + })); + const ordered = greedySlotOrder(weighted); + const thetas = sectorAngles(n, outboundTheta); + const closed = outboundTheta == null; + + const lateralHalves = ordered.map((id, i) => { + const kidNode = nodesById.get(id)!; + return aabbHalfPerp(kidNode.width, kidNode.height, thetas[i]!); + }); + + const siblingRadius = closed + ? computeRingRadius(lateralHalves, spacing) + : computeArcRadius(lateralHalves, Math.PI / n, false, spacing); + + for (let i = 0; i < ordered.length; i++) { + const kidId = ordered[i]!; + const theta = thetas[i]!; + const kidNode = nodesById.get(kidId)!; + const parentClearance = + aabbHalfRadial(parentNode.width, parentNode.height, theta) + + aabbHalfRadial(kidNode.width, kidNode.height, theta) + + spacing.ringPadding; + const r = Math.max(siblingRadius, parentClearance); + const cx = parentCenter.x + r * Math.cos(theta); + const cy = parentCenter.y + r * Math.sin(theta); + positionById.set(kidId, topLeftFromCenter({ x: cx, y: cy }, kidNode.width, kidNode.height)); + + if (isRoot) ringThetas.push(theta); + + layoutPolarTree({ + nodeId: kidId, + outboundTheta: theta, + children, + nodesById, + positionById, + ringThetas, + isRoot: false, + spacing, + weightOf, + }); + } +} + +/** Lay the island out into a scratch map with its root at the origin. */ +function layoutIslandLocally( + rootId: string, + treeEdges: ReadonlyArray<{ source: string; target: string }>, + nodesById: Map, + theta: number, + spacing: Required, + weightOf: (id: string) => number, +): Map { + const scratch = new Map(); + const rootNode = nodesById.get(rootId); + if (!rootNode) return scratch; + + scratch.set(rootId, topLeftFromCenter({ x: 0, y: 0 }, rootNode.width, rootNode.height)); + layoutPolarTree({ + nodeId: rootId, + outboundTheta: theta, + children: buildChildrenMap([...treeEdges]), + nodesById, + positionById: scratch, + ringThetas: [], + isRoot: false, + spacing, + weightOf, + }); + return scratch; +} + +/** Polar petal islands under each satellite — the same pattern as the main tree. */ +export const polarPocketStrategy: PocketStrategy = { + /** + * Lay the petals out for real and measure what they actually span. + * + * Measuring the largest single node instead — which is the obvious shortcut — + * under-reports a multi-level island by however far the recursion spreads, + * and adjacent pockets then overlap. + */ + measureLateralHalf({ islandIds, treeEdges, rootId, preferredTheta, nodesById, spacing, weightOf }) { + if (islandIds.length <= 1) { + const rootNode = nodesById.get(rootId); + return rootNode ? aabbHalfPerp(rootNode.width, rootNode.height, preferredTheta) : 0; + } + const scratch = layoutIslandLocally(rootId, treeEdges, nodesById, preferredTheta, spacing, weightOf); + return lateralHalfExtentAtAngle(islandIds, scratch, nodesById, rootId, preferredTheta); + }, + place({ islandIds, treeEdges, rootId, targetCenter, theta, nodesById, positionById, spacing, weightOf }) { + const rootNode = nodesById.get(rootId); + if (!rootNode) return; + + positionById.set(rootId, topLeftFromCenter(targetCenter, rootNode.width, rootNode.height)); + if (islandIds.length <= 1) return; + + layoutPolarTree({ + nodeId: rootId, + outboundTheta: theta, + children: buildChildrenMap([...treeEdges]), + nodesById, + positionById, + ringThetas: [], + isRoot: false, + spacing, + weightOf, + }); + }, +}; diff --git a/packages/ego-graph/src/layouts/radial-dagre.ts b/packages/ego-graph/src/layouts/radial-dagre.ts new file mode 100644 index 000000000..bfa18cbb2 --- /dev/null +++ b/packages/ego-graph/src/layouts/radial-dagre.ts @@ -0,0 +1,88 @@ +import { rankdirFromAngle, slotAngle } from '../geometry/angles'; +import { topLeftFromCenter } from '../geometry/box'; +import { computeRingRadius, lateralHalfExtent } from '../geometry/extents'; +import { descendantCount, greedySlotOrder } from '../geometry/order'; +import { resolveOptions } from '../options'; +import { buildSpanningTree, collectSubtreeIds } from '../tree'; +import type { EgoGraph, LayoutEdge, LayoutNode, LayoutOptions, Point, Positions } from '../types'; +import { dagrePocketStrategy, layoutSubtreeLocal } from './dagre-subtree'; +import { placeSubtreeAt } from './place'; +import { layoutSatellitePockets } from './pockets'; + +/** + * A ring of level-1 branches around the root, each branch drawn by Dagre + * pointing outward, then satellites in pockets on an outer arc. + * + * The workhorse: deep, uneven branches stay legible because Dagre handles the + * inside of each one. Requires `@dagrejs/dagre`. + */ +export function radialDagre( + graph: EgoGraph, + options: LayoutOptions = {}, +): Positions { + const positionById: Positions = new Map(); + if (graph.nodes.length === 0) return positionById; + + const resolved = resolveOptions(options); + const nodesById = new Map(graph.nodes.map((n) => [n.id, n])); + const tree = buildSpanningTree(graph, resolved); + if (!tree.root) return positionById; + + const weightOf = (id: string): number => { + const node = nodesById.get(id); + return node ? resolved.getWeight(node) : 1; + }; + + const rootNode = nodesById.get(tree.root)!; + const rootCenter = { x: 0, y: 0 }; + positionById.set(tree.root, topLeftFromCenter(rootCenter, rootNode.width, rootNode.height)); + + const ringThetas: number[] = []; + const level1 = tree.children.get(tree.root) ?? []; + + if (level1.length > 0) { + const ordered = greedySlotOrder(level1.map((id) => ({ id, weight: descendantCount(tree.children, id, weightOf) }))); + const n = ordered.length; + + const branches = ordered.map((id, slotIndex) => { + const theta = slotAngle(slotIndex, n); + ringThetas.push(theta); + const rankdir = rankdirFromAngle(theta); + const subtreeIds = collectSubtreeIds(tree.children, id); + const localPositions = layoutSubtreeLocal(subtreeIds, tree.edges, nodesById, rankdir, resolved); + return { + id, + theta, + subtreeIds, + localPositions, + lateralHalf: lateralHalfExtent(subtreeIds, localPositions, nodesById, id, rankdir), + }; + }); + + const radius = computeRingRadius( + branches.map((b) => b.lateralHalf), + resolved, + ); + + for (const branch of branches) { + const target: Point = { + x: rootCenter.x + radius * Math.cos(branch.theta), + y: rootCenter.y + radius * Math.sin(branch.theta), + }; + placeSubtreeAt(branch.subtreeIds, branch.localPositions, nodesById, branch.id, target, positionById); + } + } + + layoutSatellitePockets({ + graph, + tree, + nodesById, + positionById, + rootCenter, + ringThetas, + strategy: dagrePocketStrategy, + options: resolved, + }); + + return positionById; +} diff --git a/packages/ego-graph/src/layouts/sectored-dagre.ts b/packages/ego-graph/src/layouts/sectored-dagre.ts new file mode 100644 index 000000000..2de26b59c --- /dev/null +++ b/packages/ego-graph/src/layouts/sectored-dagre.ts @@ -0,0 +1,258 @@ +import { rankdirAxisAngle, rankdirFromAngle, rotateOffset, sectorAngles } from '../geometry/angles'; +import { centerOf, topLeftFromCenter } from '../geometry/box'; +import { computeArcRadius, computeRingRadius, lateralHalfExtent } from '../geometry/extents'; +import { descendantCount, greedySlotOrder } from '../geometry/order'; +import { resolveOptions } from '../options'; +import { buildChildrenMap, buildSpanningTree } from '../tree'; +import type { EgoGraph, LayoutEdge, LayoutNode, LayoutOptions, Point, Positions, SpacingOptions } from '../types'; +import { dagrePocketStrategy, layoutSubtreeLocal } from './dagre-subtree'; +import type { PocketStrategy } from './pocket-strategy'; +import { layoutSatellitePockets } from './pockets'; + +/** Above this many children, a node splits them across sectors instead of one pocket. */ +const BUSHY_THRESHOLD = 5; + +function shallowEdges(parentId: string, kidIds: string[]): LayoutEdge[] { + return kidIds.map((target) => ({ source: parentId, target })); +} + +type ShallowPocketArgs = { + parentId: string; + kidIds: string[]; + theta: number; + nodesById: Map; + positionById: Map; + spacing: Required; + /** Push the pocket out so the innermost child clears this radius from the parent. */ + ringRadius?: number; +}; + +/** + * Place direct children with a shallow Dagre pocket, rotated so the rank axis + * aligns with `theta`. Returns the pocket's lateral half-extent. + */ +function placeShallowPocket(args: ShallowPocketArgs): number { + const { parentId, kidIds, theta, nodesById, positionById, spacing, ringRadius } = args; + if (kidIds.length === 0) return 0; + + const parentPos = positionById.get(parentId); + const parentNode = nodesById.get(parentId); + if (!parentPos || !parentNode) return 0; + + const parentCenter = centerOf(parentNode, parentPos); + + const rankdir = rankdirFromAngle(theta); + const ids = [parentId, ...kidIds]; + const local = layoutSubtreeLocal(ids, shallowEdges(parentId, kidIds), nodesById, rankdir, spacing); + const lateralHalf = lateralHalfExtent(ids, local, nodesById, parentId, rankdir); + + const localParent = local.get(parentId); + if (!localParent) return lateralHalf; + const localParentCenter = { + x: localParent.x + parentNode.width / 2, + y: localParent.y + parentNode.height / 2, + }; + + const delta = theta - rankdirAxisAngle(rankdir); + const unit = { x: Math.cos(theta), y: Math.sin(theta) }; + + const placements: Array<{ id: string; offset: Point }> = []; + let minProj = Infinity; + + for (const kidId of kidIds) { + const localKid = local.get(kidId); + const kidNode = nodesById.get(kidId); + if (!localKid || !kidNode) continue; + const localKidCenter = { + x: localKid.x + kidNode.width / 2, + y: localKid.y + kidNode.height / 2, + }; + const rotated = rotateOffset(localKidCenter.x - localParentCenter.x, localKidCenter.y - localParentCenter.y, delta); + minProj = Math.min(minProj, rotated.x * unit.x + rotated.y * unit.y); + placements.push({ id: kidId, offset: rotated }); + } + + const push = ringRadius != null && Number.isFinite(minProj) ? Math.max(0, ringRadius - minProj) : 0; + + for (const { id, offset } of placements) { + const kidNode = nodesById.get(id)!; + const cx = parentCenter.x + offset.x + push * unit.x; + const cy = parentCenter.y + offset.y + push * unit.y; + positionById.set(id, topLeftFromCenter({ x: cx, y: cy }, kidNode.width, kidNode.height)); + } + + return lateralHalf; +} + +type SectorTreeArgs = { + nodeId: string; + outboundTheta: number | null; + children: Map; + nodesById: Map; + positionById: Map; + ringThetas: number[]; + isRoot: boolean; + spacing: Required; + weightOf: (id: string) => number; +}; + +function layoutSectorTree(args: SectorTreeArgs): void { + const { nodeId, outboundTheta, children, nodesById, positionById, ringThetas, isRoot, spacing, weightOf } = args; + const kidIds = children.get(nodeId) ?? []; + const n = kidIds.length; + if (n === 0) return; + + if (n < BUSHY_THRESHOLD) { + placeShallowPocket({ + parentId: nodeId, + kidIds, + theta: outboundTheta ?? Math.PI / 2, // straight down at the root + nodesById, + positionById, + spacing, + }); + } else { + const sectorCount = Math.ceil(n / BUSHY_THRESHOLD); + const ordered = greedySlotOrder(kidIds.map((id) => ({ id, weight: descendantCount(children, id, weightOf) }))); + const sectors: string[][] = Array.from({ length: sectorCount }, () => []); + ordered.forEach((id, i) => { + sectors[i % sectorCount]!.push(id); + }); + + const thetas = sectorAngles(sectorCount, outboundTheta); + const closed = outboundTheta == null; + + // Measure every sector pocket first, then pick one shared clearance radius. + const lateralHalves: number[] = []; + for (let s = 0; s < sectorCount; s++) { + const kids = sectors[s]!; + const rankdir = rankdirFromAngle(thetas[s]!); + const ids = [nodeId, ...kids]; + const local = layoutSubtreeLocal(ids, shallowEdges(nodeId, kids), nodesById, rankdir, spacing); + lateralHalves.push(lateralHalfExtent(ids, local, nodesById, nodeId, rankdir)); + } + + const radius = closed + ? computeRingRadius(lateralHalves, spacing) + : computeArcRadius(lateralHalves, Math.PI / sectorCount, false, spacing); + + for (let s = 0; s < sectorCount; s++) { + placeShallowPocket({ + parentId: nodeId, + kidIds: sectors[s]!, + theta: thetas[s]!, + nodesById, + positionById, + spacing, + ringRadius: radius, + }); + } + } + + const parentPos = positionById.get(nodeId); + const parentNode = nodesById.get(nodeId); + if (!parentPos || !parentNode) return; + const parentCenter = centerOf(parentNode, parentPos); + + for (const kidId of kidIds) { + const kidPos = positionById.get(kidId); + const kidNode = nodesById.get(kidId); + if (!kidPos || !kidNode) continue; + const kidCenter = centerOf(kidNode, kidPos); + const kidTheta = Math.atan2(kidCenter.y - parentCenter.y, kidCenter.x - parentCenter.x); + if (isRoot) ringThetas.push(kidTheta); + + layoutSectorTree({ + nodeId: kidId, + outboundTheta: kidTheta, + children, + nodesById, + positionById, + ringThetas, + isRoot: false, + spacing, + weightOf, + }); + } +} + +/** Sectored islands under each satellite — the same rules as the main tree. */ +const sectoredPocketStrategy: PocketStrategy = { + // Fanning still uses the honest Dagre measure. + measureLateralHalf: dagrePocketStrategy.measureLateralHalf, + place({ islandIds, treeEdges, rootId, targetCenter, theta, nodesById, positionById, spacing, weightOf }) { + const rootNode = nodesById.get(rootId); + if (!rootNode) return; + + positionById.set(rootId, topLeftFromCenter(targetCenter, rootNode.width, rootNode.height)); + if (islandIds.length <= 1) return; + + layoutSectorTree({ + nodeId: rootId, + outboundTheta: theta, + children: buildChildrenMap([...treeEdges]), + nodesById, + positionById, + ringThetas: [], + isRoot: false, + spacing, + weightOf, + }); + }, +}; + +/** + * Recursive sectors of shallow Dagre pockets. Every node lays its own children + * out, splitting them across sectors once there are enough to be unwieldy. + * + * Balances better than {@link radialDagre} on wide, shallow graphs, where one + * Dagre pass per level-1 branch would leave the ring lopsided. + * Requires `@dagrejs/dagre`. + */ +export function sectoredDagre( + graph: EgoGraph, + options: LayoutOptions = {}, +): Positions { + const positionById: Positions = new Map(); + if (graph.nodes.length === 0) return positionById; + + const resolved = resolveOptions(options); + const nodesById = new Map(graph.nodes.map((n) => [n.id, n])); + const tree = buildSpanningTree(graph, resolved); + if (!tree.root) return positionById; + + const weightOf = (id: string): number => { + const node = nodesById.get(id); + return node ? resolved.getWeight(node) : 1; + }; + + const rootNode = nodesById.get(tree.root)!; + const rootCenter = { x: 0, y: 0 }; + positionById.set(tree.root, topLeftFromCenter(rootCenter, rootNode.width, rootNode.height)); + + const ringThetas: number[] = []; + layoutSectorTree({ + nodeId: tree.root, + outboundTheta: null, + children: tree.children, + nodesById, + positionById, + ringThetas, + isRoot: true, + spacing: resolved, + weightOf, + }); + + layoutSatellitePockets({ + graph, + tree, + nodesById, + positionById, + rootCenter, + ringThetas, + strategy: sectoredPocketStrategy, + options: resolved, + }); + + return positionById; +} diff --git a/packages/ego-graph/src/options.ts b/packages/ego-graph/src/options.ts new file mode 100644 index 000000000..a2f28e54e --- /dev/null +++ b/packages/ego-graph/src/options.ts @@ -0,0 +1,30 @@ +import type { ClassifyOptions, LayoutEdge, LayoutNode, LayoutOptions, SpacingOptions } from './types'; + +/** + * Tuned against ~180x56 nodes. If yours are much larger, scale these up — + * nothing here derives spacing from the nodes it is spacing. + */ +export const DEFAULT_SPACING: Required = { + nodeSep: 80, + rankSep: 100, + minRingRadius: 220, + ringPadding: 60, + satelliteGap: Math.PI / 3, + minSatelliteGap: Math.PI / 12, +}; + +export type ResolvedOptions = Required & + Required>; + +/** Fill in every dial and predicate, so nothing downstream has to handle `undefined`. */ +export function resolveOptions( + options: LayoutOptions = {}, +): ResolvedOptions { + return { + ...DEFAULT_SPACING, + ...options, + isStructural: options.isStructural ?? (() => true), + isSatellite: options.isSatellite ?? (() => false), + getWeight: options.getWeight ?? (() => 1), + }; +} diff --git a/packages/ego-graph/src/react-flow/index.ts b/packages/ego-graph/src/react-flow/index.ts new file mode 100644 index 000000000..23f804aa4 --- /dev/null +++ b/packages/ego-graph/src/react-flow/index.ts @@ -0,0 +1,114 @@ +import type { EgoGraph, LayoutEdge, LayoutNode, Point, Positions } from '../types'; + +/** + * These describe React Flow's node and edge shapes structurally, so this + * adapter has no dependency on `@xyflow/react` — not even a type-only one. + * Anything with the same fields works, svelte-flow included. + */ +export type MeasuredNode = { + id: string; + position: Point; + measured?: { width?: number | null; height?: number | null } | null; + width?: number | null; + height?: number | null; +}; + +export type HandledEdge = { + source: string; + target: string; + sourceHandle?: string | null; + targetHandle?: string | null; +}; + +/** Used until React Flow has measured a node for real. */ +export const DEFAULT_NODE_SIZE = { width: 180, height: 56 }; + +export function measuredSize( + node: MeasuredNode, + fallback: { width: number; height: number } = DEFAULT_NODE_SIZE, +): { width: number; height: number } { + return { + width: node.measured?.width ?? node.width ?? fallback.width, + height: node.measured?.height ?? node.height ?? fallback.height, + }; +} + +/** + * Project React Flow nodes and edges onto the layout's minimal shape. + * + * Extra fields ride along untouched, so predicates you pass to a layout still + * see your own node — `isSatellite: n => n.type === 'pivot'` works. + */ +export function toLayoutGraph( + nodes: N[], + edges: E[], + root: string, + fallback?: { width: number; height: number }, +): EgoGraph { + return { + nodes: nodes.map((node) => ({ ...node, ...measuredSize(node, fallback) })), + edges, + root, + }; +} + +/** Write computed positions back. Nodes the layout did not place keep theirs. */ +export function applyPositions(nodes: N[], positions: Positions): N[] { + return nodes.map((node) => { + const position = positions.get(node.id); + return position ? { ...node, position } : node; + }); +} + +export type HandleSide = 't' | 'r' | 'b' | 'l'; + +/** + * Map a side to the handle id your node component renders. + * + * The default matches the eight-handle node in the README: bare sides for + * targets (`t` `r` `b` `l`), `s`-prefixed for sources (`st` `sr` `sb` `sl`). + * Override it if your nodes name their handles differently. + */ +export type HandleIdMapper = (side: HandleSide, kind: 'source' | 'target') => string; + +export const defaultHandleId: HandleIdMapper = (side, kind) => (kind === 'source' ? `s${side}` : side); + +/** Dominant axis, so side-by-side nodes connect left/right rather than top/bottom. */ +function sideFromDelta(dx: number, dy: number): HandleSide { + if (Math.abs(dx) >= Math.abs(dy)) { + return dx >= 0 ? 'r' : 'l'; + } + return dy >= 0 ? 'b' : 't'; +} + +/** + * Re-point every edge at the handles facing its counterpart. Call this *after* + * {@link applyPositions} — it reads `node.position`. + * + * Edges whose handles are already correct are returned by identity, so React + * Flow will not re-render them. + */ +export function retargetHandles( + nodes: N[], + edges: E[], + handleId: HandleIdMapper = defaultHandleId, + fallback?: { width: number; height: number }, +): Array { + const centers = new Map(); + for (const node of nodes) { + const { width, height } = measuredSize(node, fallback); + centers.set(node.id, { x: node.position.x + width / 2, y: node.position.y + height / 2 }); + } + + return edges.map((edge) => { + const from = centers.get(edge.source); + const to = centers.get(edge.target); + if (!from || !to) return edge; + + const sourceHandle = handleId(sideFromDelta(to.x - from.x, to.y - from.y), 'source'); + const targetHandle = handleId(sideFromDelta(from.x - to.x, from.y - to.y), 'target'); + if (edge.sourceHandle === sourceHandle && edge.targetHandle === targetHandle) return edge; + + return { ...edge, sourceHandle, targetHandle }; + }); +} diff --git a/packages/ego-graph/src/react-flow/react-flow.spec.ts b/packages/ego-graph/src/react-flow/react-flow.spec.ts new file mode 100644 index 000000000..5af68dbc6 --- /dev/null +++ b/packages/ego-graph/src/react-flow/react-flow.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { applyPositions, DEFAULT_NODE_SIZE, measuredSize, retargetHandles, toLayoutGraph } from './index'; + +const at = (id: string, x: number, y: number) => ({ id, position: { x, y }, measured: { width: 100, height: 40 } }); + +describe('measuredSize', () => { + it('prefers measured, then declared, then the fallback', () => { + expect(measuredSize({ id: 'a', position: { x: 0, y: 0 }, measured: { width: 10, height: 20 }, width: 99 })).toEqual( + { + width: 10, + height: 20, + }, + ); + expect(measuredSize({ id: 'a', position: { x: 0, y: 0 }, width: 99, height: 33 })).toEqual({ + width: 99, + height: 33, + }); + expect(measuredSize({ id: 'a', position: { x: 0, y: 0 } })).toEqual(DEFAULT_NODE_SIZE); + }); +}); + +describe('toLayoutGraph', () => { + it('adds a footprint while keeping the caller’s own fields', () => { + const nodes = [{ ...at('a', 0, 0), type: 'pivot' as const }]; + const result = toLayoutGraph(nodes, [{ source: 'a', target: 'b' }], 'a'); + + expect(result.root).toBe('a'); + expect(result.nodes[0]).toMatchObject({ id: 'a', width: 100, height: 40, type: 'pivot' }); + // So predicates like `n => n.type === 'pivot'` still work downstream. + expect(result.nodes[0]!.type).toBe('pivot'); + }); +}); + +describe('applyPositions', () => { + it('writes placements through and leaves unplaced nodes alone', () => { + const nodes = [at('a', 0, 0), at('b', 5, 5)]; + const result = applyPositions(nodes, new Map([['a', { x: 10, y: 20 }]])); + + expect(result[0]!.position).toEqual({ x: 10, y: 20 }); + expect(result[1]).toBe(nodes[1]); + }); +}); + +describe('retargetHandles', () => { + it('picks the dominant axis rather than the nearest corner', () => { + // b is far to the right and slightly below: that is a right/left pairing. + const nodes = [at('a', 0, 0), at('b', 500, 30)]; + const [edge] = retargetHandles(nodes, [{ source: 'a', target: 'b' }]); + + expect(edge!.sourceHandle).toBe('sr'); + expect(edge!.targetHandle).toBe('l'); + }); + + it('pairs vertically when the vertical delta dominates', () => { + const nodes = [at('a', 0, 0), at('b', 30, 500)]; + const [edge] = retargetHandles(nodes, [{ source: 'a', target: 'b' }]); + + expect(edge!.sourceHandle).toBe('sb'); + expect(edge!.targetHandle).toBe('t'); + }); + + it('honours a custom handle id mapper', () => { + const nodes = [at('a', 0, 0), at('b', 500, 0)]; + const [edge] = retargetHandles(nodes, [{ source: 'a', target: 'b' }], (side, kind) => `${kind}-${side}`); + + expect(edge!.sourceHandle).toBe('source-r'); + expect(edge!.targetHandle).toBe('target-l'); + }); + + it('returns unchanged edges by identity so React Flow does not re-render them', () => { + const nodes = [at('a', 0, 0), at('b', 500, 0)]; + const edges = [{ source: 'a', target: 'b', sourceHandle: 'sr', targetHandle: 'l' }]; + + expect(retargetHandles(nodes, edges)[0]).toBe(edges[0]); + }); + + it('leaves an edge alone when an endpoint is missing', () => { + const edges = [{ source: 'a', target: 'ghost' }]; + expect(retargetHandles([at('a', 0, 0)], edges)[0]).toBe(edges[0]); + }); +}); diff --git a/packages/ego-graph/src/test-support.ts b/packages/ego-graph/src/test-support.ts new file mode 100644 index 000000000..be2793c17 --- /dev/null +++ b/packages/ego-graph/src/test-support.ts @@ -0,0 +1,44 @@ +import type { EgoGraph, LayoutEdge, LayoutNode, Point, Positions } from './types'; + +/** Matches the fallback size the React Flow adapter uses before measurement. */ +export const NODE_SIZE = { width: 180, height: 56 }; + +export type TestNode = LayoutNode & { satellite?: true }; +export type TestEdge = LayoutEdge & { associative?: true }; + +export function node(id: string, size: { width: number; height: number } = NODE_SIZE): TestNode { + return { id, ...size }; +} + +export function satellite(id: string, size: { width: number; height: number } = NODE_SIZE): TestNode { + return { id, ...size, satellite: true }; +} + +export function link(source: string, target: string): TestEdge { + return { source, target }; +} + +export function match(source: string, target: string): TestEdge { + return { source, target, associative: true }; +} + +export const classify = { + isSatellite: (n: TestNode) => n.satellite === true, + isStructural: (e: TestEdge) => e.associative !== true, +}; + +export function graph(nodes: TestNode[], edges: TestEdge[], root: string): EgoGraph { + return { nodes, edges, root }; +} + +/** Center of `id` from a layout result. Throws rather than silently returning NaN. */ +export function centerOf(positions: Positions, nodes: TestNode[], id: string): Point { + const position = positions.get(id); + const found = nodes.find((n) => n.id === id); + if (!position || !found) throw new Error(`no placement for ${id}`); + return { x: position.x + found.width / 2, y: position.y + found.height / 2 }; +} + +export function distance(a: Point, b: Point = { x: 0, y: 0 }): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} diff --git a/packages/ego-graph/src/tree.ts b/packages/ego-graph/src/tree.ts new file mode 100644 index 000000000..56350830b --- /dev/null +++ b/packages/ego-graph/src/tree.ts @@ -0,0 +1,145 @@ +import type { ClassifyOptions, EgoGraph, LayoutEdge, LayoutNode } from './types'; + +/** Undirected adjacency restricted to `nodeIds`; endpoints outside the set are dropped. */ +export function buildUndirectedAdjacency(nodeIds: string[], edges: LayoutEdge[]): Map { + const idSet = new Set(nodeIds); + const adj = new Map(); + for (const id of nodeIds) { + adj.set(id, []); + } + for (const edge of edges) { + if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue; + adj.get(edge.source)!.push(edge.target); + adj.get(edge.target)!.push(edge.source); + } + return adj; +} + +/** `requested` when it survived filtering, otherwise the first id. `''` when there are none. */ +export function resolveRoot(nodeIds: string[], requested: string): string { + if (nodeIds.includes(requested)) return requested; + return nodeIds[0] ?? ''; +} + +/** + * Node ids reachable from `root` over the given edges, including the root. + * + * Note this walks *every* edge it is given, associative ones included — it + * answers "what is connected", not "what is in the tree". + */ +export function reachableNodeIds(nodeIds: string[], edges: LayoutEdge[], root: string): Set { + const start = resolveRoot(nodeIds, root); + if (!start) return new Set(); + + const adj = buildUndirectedAdjacency(nodeIds, edges); + const visited = new Set([start]); + const queue = [start]; + + while (queue.length > 0) { + const cur = queue.shift()!; + for (const nxt of adj.get(cur) ?? []) { + if (visited.has(nxt)) continue; + visited.add(nxt); + queue.push(nxt); + } + } + + return visited; +} + +/** + * BFS spanning-tree edges (parent → child) rooted at `rootId`. + * Cycles are handled by only recording the first discovery edge. + * `allowed`, when given, restricts the walk to that id set. + */ +export function bfsTreeEdges(rootId: string, adj: Map, allowed?: Set): LayoutEdge[] { + if (allowed && !allowed.has(rootId)) return []; + + const tree: LayoutEdge[] = []; + const visited = new Set([rootId]); + const queue = [rootId]; + + while (queue.length > 0) { + const cur = queue.shift()!; + for (const nxt of adj.get(cur) ?? []) { + if (visited.has(nxt) || (allowed && !allowed.has(nxt))) continue; + visited.add(nxt); + tree.push({ source: cur, target: nxt }); + queue.push(nxt); + } + } + + return tree; +} + +/** Parent → children adjacency from directed tree edges. */ +export function buildChildrenMap(treeEdges: LayoutEdge[]): Map { + const children = new Map(); + for (const { source, target } of treeEdges) { + const list = children.get(source) ?? []; + list.push(target); + children.set(source, list); + } + return children; +} + +/** `rootId` and every descendant, in depth-first order. */ +export function collectSubtreeIds(children: Map, rootId: string): string[] { + const ids: string[] = []; + const stack = [rootId]; + while (stack.length > 0) { + const cur = stack.pop()!; + ids.push(cur); + const kids = children.get(cur); + if (kids) { + for (let i = kids.length - 1; i >= 0; i--) { + stack.push(kids[i]!); + } + } + } + return ids; +} + +/** + * The hierarchy every pass in this package agrees on. + * + * One definition, deliberately: structural edges between non-satellite nodes, + * BFS'd from the root. Satellites are excluded here rather than filtered out + * later, so they can never reparent a branch or warp the star. + */ +export type SpanningTree = { + /** The requested root if it exists, else the first node. `''` for an empty graph. */ + root: string; + /** Parent → child edges. */ + edges: LayoutEdge[]; + /** Parent → children adjacency. */ + children: Map; + /** Ids eligible for the tree — every non-satellite node, in input order. */ + memberIds: string[]; + /** Ids `isSatellite` excluded, sorted for stable downstream iteration. */ + satelliteIds: string[]; +}; + +export function buildSpanningTree( + graph: EgoGraph, + options: Required, 'isStructural' | 'isSatellite'>>, +): SpanningTree { + const { isStructural, isSatellite } = options; + + const memberIds: string[] = []; + const satelliteIds: string[] = []; + for (const node of graph.nodes) { + (isSatellite(node) ? satelliteIds : memberIds).push(node.id); + } + satelliteIds.sort((a, b) => a.localeCompare(b)); + + const memberIdSet = new Set(memberIds); + const structural = graph.edges.filter( + (e) => isStructural(e) && memberIdSet.has(e.source) && memberIdSet.has(e.target), + ); + + const root = resolveRoot(memberIds, graph.root); + const edges = root ? bfsTreeEdges(root, buildUndirectedAdjacency(memberIds, structural)) : []; + + return { root, edges, children: buildChildrenMap(edges), memberIds, satelliteIds }; +} diff --git a/packages/ego-graph/src/types.ts b/packages/ego-graph/src/types.ts new file mode 100644 index 000000000..2238da29b --- /dev/null +++ b/packages/ego-graph/src/types.ts @@ -0,0 +1,74 @@ +export type Point = { x: number; y: number }; + +/** + * The minimal node contract: an id and a footprint. No renderer, no payload. + * Bring your own node type — anything structurally assignable works. + */ +export type LayoutNode = { id: string; width: number; height: number }; + +/** + * The minimal edge contract. Edge identity is the caller's business; the layout + * only ever needs to know what connects to what. + */ +export type LayoutEdge = { source: string; target: string }; + +/** A graph seen from one node. `root` is the ego. */ +export type EgoGraph = { + nodes: N[]; + edges: E[]; + root: string; +}; + +/** Top-left positions keyed by node id, matching the React Flow convention. */ +export type Positions = Map; + +/** Direction a Dagre sub-layout grows in. */ +export type RankDir = 'TB' | 'BT' | 'LR' | 'RL'; + +/** + * Which edges define the hierarchy, which nodes refuse to join it, and how much + * room each node's branch deserves. + * + * The two predicates are orthogonal. `isSatellite` is a *role*: a satellite is + * never in the spanning tree no matter what edges it has. `isStructural` is a + * property of the *edge*: an associative edge is rendered but never positional, + * even between two ordinary nodes. + */ +export type ClassifyOptions = { + /** Does this edge define hierarchy? Default: every edge does. */ + isStructural?: (edge: E) => boolean; + /** Should this node be kept out of the tree and parked on the periphery? Default: none are. */ + isSatellite?: (node: N) => boolean; + /** + * How many nodes does this one stand for? Default: 1. + * Return the member count for a node standing in for a folded branch, so the + * ring gives it the room its contents would have needed. + */ + getWeight?: (node: N) => number; +}; + +/** Spacing dials. All lengths in the same units as node width/height. */ +export type SpacingOptions = { + /** Gap between siblings within a Dagre sub-layout. */ + nodeSep?: number; + /** Gap between ranks within a Dagre sub-layout. */ + rankSep?: number; + /** Floor on the first ring's radius, so level-1 nodes clear the root. */ + minRingRadius?: number; + /** Extra space between adjacent subtrees' lateral extents. */ + ringPadding?: number; + /** + * Satellites whose preferred angles fall within this circular distance count + * as wanting the same side, and get fanned along an outer arc rather than + * stacked on one ray. + */ + satelliteGap?: number; + /** Floor on the angular separation between fanned satellites. */ + minSatelliteGap?: number; +}; + +export type LayoutOptions = ClassifyOptions< + N, + E +> & + SpacingOptions; diff --git a/packages/ego-graph/tsconfig.json b/packages/ego-graph/tsconfig.json new file mode 100644 index 000000000..2f7c8a8f5 --- /dev/null +++ b/packages/ego-graph/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["vitest/globals"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noFallthroughCasesInSwitch": true, + + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "pretty": true + }, + "include": ["**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/ego-graph/tsdown.config.ts b/packages/ego-graph/tsdown.config.ts new file mode 100644 index 000000000..c44c09224 --- /dev/null +++ b/packages/ego-graph/tsdown.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + // Named entries rather than a glob: three files called index.ts would + // otherwise collide, and these names are the published subpaths. + entry: { + index: 'src/index.ts', + fold: 'src/fold/index.ts', + 'react-flow': 'src/react-flow/index.ts', + }, + // ESM only. Every consumer of a React Flow layout is running a bundler, and + // dual-publishing buys compatibility nobody here needs at the cost of the + // dual-package hazard. + format: ['esm'], + dts: true, + clean: true, + treeshake: true, + // The one optional peer. Bundling it would defeat the point of keeping + // polarPetal dependency-free. + deps: { neverBundle: ['@dagrejs/dagre'] }, +}); diff --git a/packages/ego-graph/vite.config.ts b/packages/ego-graph/vite.config.ts new file mode 100644 index 000000000..575535c59 --- /dev/null +++ b/packages/ego-graph/vite.config.ts @@ -0,0 +1,10 @@ +import viteTsConfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [viteTsConfigPaths()], + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 0a67c865b..f105cb4d8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -26,7 +26,10 @@ "ui-design-system": ["packages/ui-design-system/src/index.ts"], "@app-builder/*": ["packages/app-builder/src/*"], "@ast-builder/*": ["packages/app-builder/src/components/AstBuilder/*"], - "@marble/shared": ["packages/shared/src/index.ts"] + "@marble/shared": ["packages/shared/src/index.ts"], + "ego-graph": ["packages/ego-graph/src/index.ts"], + "ego-graph/fold": ["packages/ego-graph/src/fold/index.ts"], + "ego-graph/react-flow": ["packages/ego-graph/src/react-flow/index.ts"] }, "pretty": true },