├── .github └── workflows │ ├── nodejs.yml │ └── typedoc.yml ├── .gitignore ├── .npmignore ├── .prettierrc.toml ├── .watchmanconfig ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bun.lockb ├── eslint.config.mjs ├── examples ├── dag.json ├── en.json ├── ex.json ├── genealogy.json ├── grafo.json ├── materials.json ├── square.json ├── triangle.json └── zherebko.json ├── flow.mjs ├── package.json ├── resources ├── grid-greedy-bottomup.png ├── grid-greedy-topdown.png ├── grid-opt.png ├── sugi-coffmangraham-opt-quad.png ├── sugi-longestpath-opt-quad.png ├── sugi-simplex-opt-center.png ├── sugi-simplex-opt-greedy.png ├── sugi-simplex-opt-quad.png ├── sugi-simplex-twolayer-quad.png ├── sugi-simplex-twolayer-simplex.png ├── sugi-topological-opt-topological.png └── zherebko.png ├── src ├── collections.test.ts ├── collections.ts ├── graph │ ├── connect.test.ts │ ├── connect.ts │ ├── hierarchy.test.ts │ ├── hierarchy.ts │ ├── index.test.ts │ ├── index.ts │ ├── json.test.ts │ ├── json.ts │ ├── stratify.test.ts │ ├── stratify.ts │ ├── utils.test.ts │ └── utils.ts ├── grid │ ├── index.test.ts │ ├── index.ts │ └── lane │ │ ├── greedy.test.ts │ │ ├── greedy.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── opt.test.ts │ │ ├── opt.ts │ │ ├── test-utils.ts │ │ └── utils.ts ├── index.test.ts ├── index.ts ├── iters.test.ts ├── iters.ts ├── layout.test.ts ├── layout.ts ├── simplex.ts ├── sugiyama │ ├── coord │ │ ├── center.test.ts │ │ ├── center.ts │ │ ├── greedy.test.ts │ │ ├── greedy.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── quad.test.ts │ │ ├── quad.ts │ │ ├── simplex.test.ts │ │ ├── simplex.ts │ │ ├── topological.test.ts │ │ ├── topological.ts │ │ └── utils.ts │ ├── decross │ │ ├── dfs.test.ts │ │ ├── dfs.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── opt.test.ts │ │ ├── opt.ts │ │ ├── two-layer.test.ts │ │ └── two-layer.ts │ ├── index.test.ts │ ├── index.ts │ ├── layering │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── longest-path.test.ts │ │ ├── longest-path.ts │ │ ├── simplex.test.ts │ │ ├── simplex.ts │ │ ├── test-utils.ts │ │ ├── topological.test.ts │ │ └── topological.ts │ ├── sugify.test.ts │ ├── sugify.ts │ ├── test-utils.ts │ ├── twolayer │ │ ├── agg.test.ts │ │ ├── agg.ts │ │ ├── greedy.test.ts │ │ ├── greedy.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── opt.test.ts │ │ └── opt.ts │ ├── utils.test.ts │ └── utils.ts ├── test-graphs.ts ├── test-utils.ts ├── tweaks.test.ts ├── tweaks.ts ├── types.d.ts ├── utils.test.ts ├── utils.ts └── zherebko │ ├── greedy.ts │ ├── index.test.ts │ └── index.ts ├── tsconfig.build.json ├── tsconfig.json └── typedoc.json /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: oven-sh/setup-bun@v2 16 | - run: bun install 17 | - run: bun fmt --check 18 | - run: bun lint 19 | - run: bun export 20 | -------------------------------------------------------------------------------- /.github/workflows/typedoc.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: deploy 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Use Bun 35 | uses: oven-sh/setup-bun@v2 36 | - name: Setup Bun 37 | run: bun install 38 | - name: Build Docs 39 | run: bun doc 40 | - name: Setup Pages 41 | uses: actions/configure-pages@v5 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | # Upload docs folder 46 | path: './docs' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node # 2 | ######## 3 | node_modules 4 | 5 | # Cache # 6 | ######### 7 | .cache 8 | coverage 9 | .eslintcache 10 | tsconfig.tsbuildinfo 11 | .flow_cache 12 | .cache 13 | 14 | # Artifacts # 15 | ############# 16 | dist 17 | bundle 18 | docs 19 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /dist/*.zip 2 | /test/ 3 | /examples/ 4 | /resources/ 5 | .*.un~ 6 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | trailingComma = "none" 2 | arrowParens = "always" 3 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # All files 2 | * @erikbrinkman 3 | 4 | # Arquint layout files 5 | arquint/ @ArquintL @erikbrinkman 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at erik.brinkman@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Thanks for contributing! 5 | These guidelines should probably be fleshed out at some point, but it doesn't seem necessary yet. 6 | PRs are greatly appreciated! 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Erik Brinkman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3-dag 2 | 3 | [![npm](https://img.shields.io/npm/v/d3-dag.svg)](https://www.npmjs.com/package/d3-dag) 4 | [![build](https://github.com/erikbrinkman/d3-dag/workflows/build/badge.svg)](https://github.com/erikbrinkman/d3-dag/actions) 5 | [![docs](https://img.shields.io/badge/docs-docs-informational)](https://erikbrinkman.github.io/d3-dag/modules.html) 6 | 7 | Often data sets are hierarchical, but are not in a tree structure, such as genetic data. 8 | In these instances `d3-hierarchy` may not suit your needs, which is why `d3-dag` (Directed Acyclic Graph) exists. 9 | This module implements a data structure for manipulating DAGs. 10 | Old versions were designed to mimic `d3-hierarchy`'s api as much as possible, newer versions have opted to use modern javascript conventions while breaking from the standard set by d3. 11 | 12 | ## Examples 13 | 14 | - **Sugiyama** [[codepen](https://codepen.io/brinkbot/pen/oNQwNRv)] [[observable](https://observablehq.com/@erikbrinkman/d3-dag-sugiyama)] [[api](https://erikbrinkman.github.io/d3-dag/functions/sugiyama-1.html)] - a robust layered layout 15 | - **Zherebko** [[codepen](https://codepen.io/brinkbot/pen/dyQRPMY)] [[observable](https://observablehq.com/d/9ce02b308bb2b138)] [[api](https://erikbrinkman.github.io/d3-dag/functions/zherebko-1.html)] - a linear topological layout 16 | - **Grid** [[codepen](https://codepen.io/brinkbot/pen/eYQRmzx)] [[observable](https://observablehq.com/@erikbrinkman/d3-dag-topological)] [[api](https://erikbrinkman.github.io/d3-dag/functions/grid-1.html)] - a grid based topological layout 17 | - **Dynamic** [[codepen](https://codepen.io/brinkbot/pen/dyQRPpG)] - a dynamic sugiyama layout, click on nodes to activate or deactivate them 18 | 19 | ## Status 20 | 21 | > :warning: **tl;dr** this is effectively in light maintanence mode: simple feature requests may still be implemented, but I won't be trying to expand to new use cases 22 | 23 | This project started years ago with the intention of providing a rough 24 | framework for implementing or extending a sugiyama-style layout for small to 25 | medium sized static DAGs. At the time, this was one of the only libraries to 26 | support layered graph layouts in javascript. Since then many more libraries 27 | exist, and since I no longer use it, it's been hard to actively develop. 28 | 29 | In addition, I started this mostly for experimentation purposes, but most 30 | people just want something reasonable out of the box, that works for most 31 | inputs. Fully supporting that would take a different library, but fortunately 32 | there are several: (Note this list may not be up to date, but PRs are welcome) 33 | 34 | - [react flow](https://reactflow.dev/) - an interactive flow chart react library. 35 | This focuses more interaction than layout, and has many more included features, 36 | but for people starting out, it might provide much more for free. 37 | - [graphology](https://www.npmjs.com/package/graphology) - a general javascript 38 | graph library that's similar to the graph implementation provided as part of 39 | this library. 40 | - [sigma](https://www.npmjs.com/package/sigma) - a graph layout library 41 | specifically targeted at large graphs. 42 | 43 | ## Installing 44 | 45 | If you use node, `npm i d3-dag` or `yarn add d3-dag`. 46 | Otherwise you can load it using `unpkg`: 47 | 48 | ```html 49 | 50 | 64 | ``` 65 | 66 | ## General Usage Notes 67 | 68 | This library is built around the concept of operators. 69 | Operators are functions with a fluent interface to modify their behavior. 70 | Every function that modifies behavior returns a copy, and will not modify the original operator. 71 | For example, the `stratify` operator creates dags from id-based parent data, can be used like so: 72 | 73 | ```ts 74 | // note initial function call with no arguments to create default operator 75 | const stratify = graphStratify(); 76 | const dag = stratify([{ id: "parent" }, { id: "child", parentIds: ["parent"] }]); 77 | 78 | stratify.id(({ myid }: { myid: string }) => myid); 79 | // doesn't work, stratify was not modified 80 | const dag = stratify([{ myid: "parent" }, { myid: "child", parentIds: ["parent"] }]); 81 | 82 | const myStratify = stratify.id(({ myid }: { myid: string }) => myid); 83 | // works! 84 | const dag = myStratify([{ myid: "parent" }, { myid: "child", parentIds: ["parent"] }]); 85 | ``` 86 | 87 | ## Updating 88 | 89 | For information about changes between releases see the [`changelog`](CHANGELOG.md). 90 | 91 | ## Contributing 92 | 93 | Contributions, issues, and PRs are all welcome! 94 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/bun.lockb -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import spellcheck from "eslint-plugin-spellcheck"; 3 | import tsdoc from "eslint-plugin-tsdoc"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommendedTypeChecked, 9 | //...tseslint.configs.stylisticTypeChecked, 10 | //...tseslint.configs.strictTypeChecked, 11 | { 12 | languageOptions: { 13 | parserOptions: { 14 | project: true, 15 | tsconfigRootDir: import.meta.dirname, 16 | }, 17 | }, 18 | plugins: { 19 | spellcheck, 20 | tsdoc, 21 | }, 22 | rules: { 23 | "no-console": "error", 24 | "tsdoc/syntax": "error", 25 | "prefer-const": [ 26 | "error", 27 | { 28 | destructuring: "all", 29 | }, 30 | ], 31 | "no-warning-comments": [ 32 | "error", 33 | { 34 | terms: ["fixme"], 35 | location: "anywhere", 36 | }, 37 | ], 38 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 39 | "@typescript-eslint/no-unused-vars": [ 40 | "error", 41 | { 42 | varsIgnorePattern: "^_+$", 43 | }, 44 | ], 45 | "spellcheck/spell-checker": [ 46 | "error", 47 | { 48 | identifiers: false, 49 | skipWords: [ 50 | "Awan", 51 | "Azura", 52 | "Emden", 53 | "Flowgen", 54 | "Flowtype", 55 | "Gansner", 56 | "Noam", 57 | "accessor", 58 | "accessors", 59 | "acyclic", 60 | "advisee", 61 | "aggregator", 62 | "aggregators", 63 | "bidirectionalizes", 64 | "bigrams", 65 | "bottomup", 66 | "coffman", 67 | "coffmangraham", 68 | "contravariant", 69 | "coord", 70 | "covariant", 71 | "curviness", 72 | "customizable", 73 | "decrement", 74 | "decross", 75 | "decrossed", 76 | "decrossing", 77 | "decrossings", 78 | "decycle", 79 | "dedup", 80 | "deserializing", 81 | "directionally", 82 | "ecode", 83 | "esnext", 84 | "grafo", 85 | "graphvis", 86 | "hydrator", 87 | "idescendants", 88 | "iife", 89 | "ilinks", 90 | "indeg", 91 | "infeasible", 92 | "initializers", 93 | "inits", 94 | "invariants", 95 | "iroots", 96 | "isplit", 97 | "iter", 98 | "iterables", 99 | "javascript", 100 | "lagrangian", 101 | "laidout", 102 | "longestpath", 103 | "minimizers", 104 | "multidag", 105 | "multigraph", 106 | "multimap", 107 | "multitree", 108 | "nchild", 109 | "nchildren", 110 | "negatable", 111 | "outdeg", 112 | "parametrize", 113 | "quadprog", 114 | "radix", 115 | "readonly", 116 | "rect", 117 | "replacer", 118 | "rescale", 119 | "rescaled", 120 | "resized", 121 | "resizing", 122 | "suboptimal", 123 | "sugi", 124 | "sugify", 125 | "sugiyama", 126 | "tabularesque", 127 | "topdown", 128 | "transpiled", 129 | "transpiling", 130 | "twolayer", 131 | "unordered", 132 | "unranked", 133 | "unsugify", 134 | "vals", 135 | "vert", 136 | "verticality", 137 | "zherebko", 138 | ], 139 | minLength: 4, 140 | }, 141 | ], 142 | }, 143 | }, 144 | ); 145 | -------------------------------------------------------------------------------- /examples/dag.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "0", 4 | "parentIds": [] 5 | }, 6 | { 7 | "id": "1", 8 | "parentIds": ["0"] 9 | }, 10 | { 11 | "id": "2", 12 | "parentIds": ["0"] 13 | }, 14 | { 15 | "id": "3", 16 | "parentIds": ["1", "2"] 17 | }, 18 | { 19 | "id": "4", 20 | "parentIds": ["0"] 21 | }, 22 | { 23 | "id": "5", 24 | "parentIds": ["4"] 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /examples/en.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "0" 4 | }, 5 | { 6 | "id": "1" 7 | }, 8 | { 9 | "id": "2", 10 | "parentIds": ["0", "1"] 11 | }, 12 | { 13 | "id": "3", 14 | "parentIds": ["1"] 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /examples/ex.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "0" 4 | }, 5 | { 6 | "id": "1", 7 | "parentIds": ["0"] 8 | }, 9 | { 10 | "id": "2" 11 | }, 12 | { 13 | "id": "3", 14 | "parentIds": ["1", "2"] 15 | }, 16 | { 17 | "id": "4", 18 | "parentIds": ["3"] 19 | }, 20 | { 21 | "id": "5", 22 | "parentIds": ["3"] 23 | }, 24 | { 25 | "id": "6", 26 | "parentIds": ["5"] 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /examples/grafo.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "0", 4 | "parentIds": ["8"] 5 | }, 6 | { 7 | "id": "1", 8 | "parentIds": [] 9 | }, 10 | { 11 | "id": "2", 12 | "parentIds": [] 13 | }, 14 | { 15 | "id": "3", 16 | "parentIds": ["11"] 17 | }, 18 | { 19 | "id": "4", 20 | "parentIds": ["12"] 21 | }, 22 | { 23 | "id": "5", 24 | "parentIds": ["18"] 25 | }, 26 | { 27 | "id": "6", 28 | "parentIds": ["9", "15", "17"] 29 | }, 30 | { 31 | "id": "7", 32 | "parentIds": ["3", "17", "20", "21"] 33 | }, 34 | { 35 | "id": "8", 36 | "parentIds": [] 37 | }, 38 | { 39 | "id": "9", 40 | "parentIds": ["4"] 41 | }, 42 | { 43 | "id": "10", 44 | "parentIds": ["16", "21"] 45 | }, 46 | { 47 | "id": "11", 48 | "parentIds": ["2"] 49 | }, 50 | { 51 | "id": "12", 52 | "parentIds": ["21"] 53 | }, 54 | { 55 | "id": "13", 56 | "parentIds": ["4", "12"] 57 | }, 58 | { 59 | "id": "14", 60 | "parentIds": ["1", "8"] 61 | }, 62 | { 63 | "id": "15", 64 | "parentIds": [] 65 | }, 66 | { 67 | "id": "16", 68 | "parentIds": ["0"] 69 | }, 70 | { 71 | "id": "17", 72 | "parentIds": ["19"] 73 | }, 74 | { 75 | "id": "18", 76 | "parentIds": ["9"] 77 | }, 78 | { 79 | "id": "19", 80 | "parentIds": [] 81 | }, 82 | { 83 | "id": "20", 84 | "parentIds": ["13"] 85 | }, 86 | { 87 | "id": "21", 88 | "parentIds": [] 89 | } 90 | ] 91 | -------------------------------------------------------------------------------- /examples/square.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "0", 4 | "parentIds": [] 5 | }, 6 | { 7 | "id": "1", 8 | "parentIds": ["0"] 9 | }, 10 | { 11 | "id": "2", 12 | "parentIds": ["0"] 13 | }, 14 | { 15 | "id": "3", 16 | "parentIds": ["1", "2"] 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /examples/triangle.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "0", 4 | "parentIds": [] 5 | }, 6 | { 7 | "id": "1", 8 | "parentIds": ["0"] 9 | }, 10 | { 11 | "id": "2", 12 | "parentIds": ["0", "1"] 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /examples/zherebko.json: -------------------------------------------------------------------------------- 1 | [ 2 | ["1", "2"], 3 | ["1", "5"], 4 | ["1", "7"], 5 | ["2", "3"], 6 | ["2", "4"], 7 | ["2", "5"], 8 | ["2", "7"], 9 | ["2", "8"], 10 | ["3", "6"], 11 | ["3", "8"], 12 | ["4", "7"], 13 | ["5", "7"], 14 | ["5", "8"], 15 | ["5", "9"], 16 | ["6", "8"], 17 | ["7", "8"], 18 | ["9", "10"], 19 | ["9", "11"] 20 | ] 21 | -------------------------------------------------------------------------------- /flow.mjs: -------------------------------------------------------------------------------- 1 | import { beautify, compiler } from "flowgen"; 2 | import { readFile, stat, writeFile } from "fs/promises"; 3 | import { glob } from "glob"; 4 | import { performance } from "perf_hooks"; 5 | 6 | const cacheFilename = ".flow_cache"; 7 | 8 | async function getCache() { 9 | try { 10 | return JSON.parse(await readFile(cacheFilename, "utf-8")); 11 | } catch { 12 | return {}; 13 | } 14 | } 15 | 16 | async function saveCache(cache) { 17 | await writeFile(cacheFilename, JSON.stringify(cache)); 18 | } 19 | 20 | async function isCached(cache, sourceFilename, sourceMtime, destFilename) { 21 | const times = cache[sourceFilename]; 22 | if (!times) return false; 23 | const [smtime, dmtime] = times; 24 | if (smtime !== sourceMtime) return false; 25 | try { 26 | const dinfo = await stat(destFilename); 27 | return dinfo.mtimeMs === dmtime; 28 | } catch { 29 | return false; 30 | } 31 | } 32 | 33 | async function transpile(cache, sourceFilename) { 34 | const start = performance.now(); 35 | const destFilename = `${sourceFilename.slice(0, -5)}.js.flow`; 36 | const sourceInfo = await stat(sourceFilename); 37 | 38 | let verb; 39 | if (await isCached(cache, sourceFilename, sourceInfo.mtimeMs, destFilename)) { 40 | verb = "cached"; 41 | } else { 42 | const contents = await readFile(sourceFilename, "utf-8"); 43 | const flowdef = beautify(compiler.compileDefinitionString(contents)); 44 | const withHeader = `/** 45 | * Flowtype definitions for ${sourceFilename} 46 | * Generated by Flowgen from a Typescript Definition 47 | * @flow 48 | */ 49 | ${flowdef}`; 50 | await writeFile(destFilename, withHeader); 51 | const destInfo = await stat(destFilename); 52 | cache[sourceFilename] = [sourceInfo.mtimeMs, destInfo.mtimeMs]; 53 | verb = "transpiled"; 54 | } 55 | const end = performance.now(); 56 | console.log(`${sourceFilename} ${verb} in ${(end - start).toFixed(1)}ms`); 57 | } 58 | 59 | const totalStart = performance.now(); 60 | const useCache = process.argv[2] === "--cache"; 61 | 62 | const [rootCache, files] = await Promise.all([ 63 | useCache ? getCache() : {}, 64 | glob("dist/**/*.d.ts"), 65 | ]); 66 | await Promise.all(files.map((file) => transpile(rootCache, file))); 67 | await saveCache(rootCache); 68 | const totalEnd = performance.now(); 69 | console.log( 70 | `finished flow transpiling in ${(totalEnd - totalStart).toFixed(1)}ms`, 71 | ); 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-dag", 3 | "version": "1.1.0", 4 | "description": "Layout algorithms for visualizing directed acylic graphs.", 5 | "keywords": [ 6 | "d3", 7 | "d3-module", 8 | "layout", 9 | "dag", 10 | "infovis" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/erikbrinkman/d3-dag.git" 15 | }, 16 | "author": { 17 | "name": "Erik Brinkman", 18 | "email": "erik.brinkman@gmail.com" 19 | }, 20 | "license": "MIT", 21 | "type": "module", 22 | "module": "dist/d3-dag.esm.min.js", 23 | "files": [ 24 | "/dist/**/*.js", 25 | "/dist/**/*.d.ts" 26 | ], 27 | "scripts": { 28 | "fmt": "prettier --write --cache '{src,test-d}/**/*.ts' '*.json' 'examples/*.json' flow.mjs eslint.config.mjs", 29 | "lint:ts": "tsc", 30 | "lint:es": "eslint --cache 'src/**/*.ts'", 31 | "lint:doc": "typedoc --emit none", 32 | "lint": "bun lint:ts && bun lint:doc && bun lint:es", 33 | "export:ts": "tsc -p tsconfig.build.json", 34 | "export:flow": "bun flow.mjs --cache", 35 | "export:bundle": "bun build src/index.ts --minify --outfile dist/d3-dag.esm.min.js -e child_process", 36 | "export": "bun export:ts && bun export:bundle", 37 | "prepack": "bun lint && bun test --coverage && bun export", 38 | "doc": "typedoc", 39 | "doc:watch": "typedoc --watch" 40 | }, 41 | "dependencies": { 42 | "d3-array": "^3.2.4", 43 | "javascript-lp-solver": "^0.4.24", 44 | "quadprog": "^1.6.1", 45 | "stringify-object": "^5.0.0" 46 | }, 47 | "devDependencies": { 48 | "@eslint/eslintrc": "^3.2.0", 49 | "@eslint/js": "^9.17.0", 50 | "@types/d3-array": "^3.2.1", 51 | "@types/eslint__js": "^8.42.3", 52 | "@types/stringify-object": "^4.0.5", 53 | "eslint": "^9.17.0", 54 | "eslint-plugin-spellcheck": "^0.0.20", 55 | "eslint-plugin-tsdoc": "^0.4.0", 56 | "flowgen": "^1.21.0", 57 | "glob": "^11.0.0", 58 | "prettier": "^3.4.2", 59 | "prettier-plugin-organize-imports": "^4.1.0", 60 | "typedoc": "^0.27.5", 61 | "typescript": "~5.7.2", 62 | "typescript-eslint": "^8.18.1", 63 | "@types/bun": "^1.1.14" 64 | }, 65 | "prettier": { 66 | "plugins": [ 67 | "prettier-plugin-organize-imports" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /resources/grid-greedy-bottomup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/grid-greedy-bottomup.png -------------------------------------------------------------------------------- /resources/grid-greedy-topdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/grid-greedy-topdown.png -------------------------------------------------------------------------------- /resources/grid-opt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/grid-opt.png -------------------------------------------------------------------------------- /resources/sugi-coffmangraham-opt-quad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-coffmangraham-opt-quad.png -------------------------------------------------------------------------------- /resources/sugi-longestpath-opt-quad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-longestpath-opt-quad.png -------------------------------------------------------------------------------- /resources/sugi-simplex-opt-center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-simplex-opt-center.png -------------------------------------------------------------------------------- /resources/sugi-simplex-opt-greedy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-simplex-opt-greedy.png -------------------------------------------------------------------------------- /resources/sugi-simplex-opt-quad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-simplex-opt-quad.png -------------------------------------------------------------------------------- /resources/sugi-simplex-twolayer-quad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-simplex-twolayer-quad.png -------------------------------------------------------------------------------- /resources/sugi-simplex-twolayer-simplex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-simplex-twolayer-simplex.png -------------------------------------------------------------------------------- /resources/sugi-topological-opt-topological.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/sugi-topological-opt-topological.png -------------------------------------------------------------------------------- /resources/zherebko.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikbrinkman/d3-dag/8ee32f5c8ccc7323cddef810af257ffc691509d7/resources/zherebko.png -------------------------------------------------------------------------------- /src/collections.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { setIntersect, setMultimapDelete, setPop } from "./collections"; 3 | 4 | test("setPop()", () => { 5 | expect(setPop(new Set())).toBeUndefined(); 6 | expect(setPop(new Set(["a"]))).toBe("a"); 7 | expect(["a", "b"]).toContain(setPop(new Set(["a", "b"]))); 8 | }); 9 | 10 | test("setMultimapDelete()", () => { 11 | const empty = new Map>(); 12 | setMultimapDelete(empty, "", 0); 13 | expect(empty).toEqual(new Map()); 14 | }); 15 | 16 | test("setIntersect()", () => { 17 | expect(setIntersect(new Set([1]), new Set([2]))).toBeFalsy(); 18 | expect(setIntersect(new Set([1]), new Set([1, 2]))).toBeTruthy(); 19 | expect(setIntersect(new Set([2, 1]), new Set([1]))).toBeTruthy(); 20 | }); 21 | -------------------------------------------------------------------------------- /src/collections.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities for working with collections 3 | * 4 | * @packageDocumentation 5 | */ 6 | 7 | /** determines if two sets are equal */ 8 | export function setEqual(first: Set, second: Set): boolean { 9 | if (second.size !== first.size) { 10 | return false; 11 | } else { 12 | for (const element of first) { 13 | if (!second.has(element)) { 14 | return false; 15 | } 16 | } 17 | return true; 18 | } 19 | } 20 | 21 | /** determines if two sets intersect */ 22 | export function setIntersect(first: Set, second: Set): boolean { 23 | if (second.size < first.size) { 24 | [second, first] = [first, second]; 25 | } 26 | for (const element of first) { 27 | if (second.has(element)) { 28 | return true; 29 | } 30 | } 31 | return false; 32 | } 33 | 34 | /** 35 | * returns a single arbitrary element from the Set, or undefined if empty 36 | */ 37 | export function setNext(elems: Set): T | undefined { 38 | for (const elem of elems) return elem; 39 | return undefined; 40 | } 41 | 42 | /** 43 | * removes a single arbitrary element from the Set, or undefined if missing 44 | * 45 | * @remarks 46 | * if the set contains undefined, then this doesn't distinguish in output, 47 | * but will properly remove it. 48 | */ 49 | export function setPop(elems: Set): T | undefined { 50 | for (const elem of elems) { 51 | elems.delete(elem); 52 | return elem; 53 | } 54 | return undefined; 55 | } 56 | 57 | /** 58 | * push val onto key list for multimap 59 | */ 60 | export function listMultimapPush( 61 | multimap: Map, 62 | key: K, 63 | val: V, 64 | ): void { 65 | const value = multimap.get(key); 66 | if (value === undefined) { 67 | multimap.set(key, [val]); 68 | } else { 69 | value.push(val); 70 | } 71 | } 72 | 73 | /** 74 | * add val to key set for multimap 75 | */ 76 | export function setMultimapAdd( 77 | multimap: Map>, 78 | key: K, 79 | val: V, 80 | ): void { 81 | const value = multimap.get(key); 82 | if (value === undefined) { 83 | multimap.set(key, new Set([val])); 84 | } else { 85 | value.add(val); 86 | } 87 | } 88 | 89 | /** 90 | * delete val from key set for multimap 91 | */ 92 | export function setMultimapDelete( 93 | multimap: Map>, 94 | key: K, 95 | val: V, 96 | ): void { 97 | const value = multimap.get(key); 98 | if (value !== undefined) { 99 | value.delete(val); 100 | if (!value.size) { 101 | multimap.delete(key); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/graph/hierarchy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { MutGraph } from "."; 3 | import { map } from "../iters"; 4 | import { graphHierarchy } from "./hierarchy"; 5 | 6 | // initial types 7 | interface Init { 8 | children?: Iterable | undefined; 9 | } 10 | 11 | interface Hierarchy { 12 | (...inp: N[]): MutGraph; 13 | } 14 | 15 | interface Datum { 16 | id: string; 17 | children?: Datum[] | undefined; 18 | } 19 | 20 | const tail: Datum = { id: "d" }; 21 | const square: Datum = { 22 | id: "a", 23 | children: [ 24 | { 25 | id: "b", 26 | children: [tail], 27 | }, 28 | { 29 | id: "c", 30 | children: [tail], 31 | }, 32 | ], 33 | }; 34 | 35 | function typedChildren(datum: Datum): Datum[] | undefined { 36 | return datum.children; 37 | } 38 | 39 | test("graphHierarchy() parses minimal graph", () => { 40 | const single = { id: "a" }; 41 | 42 | const init = graphHierarchy() satisfies Hierarchy; 43 | // @ts-expect-error invalid types 44 | init satisfies Hierarchy; 45 | 46 | const build = init.children(typedChildren); 47 | build satisfies Hierarchy; 48 | // @ts-expect-error invalid types 49 | build satisfies Hierarchy; 50 | 51 | expect(build.children() satisfies typeof typedChildren).toBe(typedChildren); 52 | expect([...(build.childrenData()(single, 0) ?? [])]).toHaveLength(0); 53 | const graph = build(single); 54 | expect(graph.nnodes()).toBe(1); 55 | const ids = [...map(graph.nodes(), ({ data }) => data.id)]; 56 | expect(ids).toEqual(["a"]); 57 | }); 58 | 59 | test("graphHierarchy() parses a simple square", () => { 60 | const build = graphHierarchy().children(typedChildren); 61 | const graph = build(square); 62 | expect(graph.nnodes()).toBe(4); 63 | }); 64 | 65 | test("graphHierarchy() parses simple v", () => { 66 | const build = graphHierarchy(); 67 | const graph = build(...(square.children ?? [])); 68 | expect(graph.nnodes()).toBe(3); 69 | }); 70 | 71 | interface ComplexDatum { 72 | c?: [ComplexDatum, string][]; 73 | } 74 | 75 | test("graphHierarchy() works with custom operators", () => { 76 | const t: ComplexDatum = {}; 77 | const s: ComplexDatum = { 78 | c: [ 79 | [ 80 | { 81 | c: [[t, "b -> d"]], 82 | }, 83 | "a -> b", 84 | ], 85 | [ 86 | { 87 | c: [[t, "c -> d"]], 88 | }, 89 | "a -> c", 90 | ], 91 | ], 92 | }; 93 | 94 | function newChildData({ 95 | c, 96 | }: ComplexDatum): Iterable | undefined { 97 | return c; 98 | } 99 | 100 | const init = graphHierarchy() satisfies Hierarchy; 101 | // @ts-expect-error invalid types 102 | init satisfies Hierarchy; 103 | 104 | const build = init.childrenData(newChildData); 105 | build satisfies Hierarchy; 106 | // @ts-expect-error invalid types 107 | build satisfies Hierarchy; 108 | 109 | expect(build.children().wrapped).toBe(newChildData); 110 | expect(build.childrenData() satisfies typeof newChildData).toBe(newChildData); 111 | expect([...(build.children()(s, 0) ?? [])]).toHaveLength(2); 112 | expect([...(build.children()(t, 4) ?? [])]).toHaveLength(0); 113 | 114 | const graph = build(s); 115 | expect(graph.nnodes()).toBe(4); 116 | 117 | const single = build(t); 118 | expect(single.nnodes()).toBe(1); 119 | }); 120 | 121 | test("graphHierarchy() handles a cycle", () => { 122 | const three: Datum = { id: "3", children: [] }; 123 | const two: Datum = { id: "2", children: [three] }; 124 | const one = { 125 | id: "1", 126 | children: [two], 127 | }; 128 | three.children!.push(one); 129 | const build = graphHierarchy(); 130 | const graph = build(one, one); 131 | expect(graph.nnodes()).toBe(3); 132 | }); 133 | 134 | test("graphHierarchy() works for multi-graph", () => { 135 | const two: Datum = { id: "2", children: [] }; 136 | const one: Datum = { id: "1", children: [two, two] }; 137 | const build = graphHierarchy(); 138 | const graph = build(one); 139 | expect(graph.multi()).toBe(true); 140 | expect(graph.nnodes()).toBe(2); 141 | expect([...graph.links()]).toHaveLength(2); 142 | }); 143 | 144 | test("graphHierarchy() fails with self loop", () => { 145 | const selfLoop: Datum = { id: "2", children: [] }; 146 | selfLoop.children!.push(selfLoop); 147 | const line = { 148 | id: "1", 149 | children: [selfLoop], 150 | }; 151 | const build = graphHierarchy(); 152 | expect(() => build(line)).toThrow("self loop"); 153 | }); 154 | 155 | test("graphHierarchy() throws for nonempty input", () => { 156 | expect(() => { 157 | // @ts-expect-error no args 158 | graphHierarchy(null); 159 | }).toThrow("got arguments to graphHierarchy"); 160 | }); 161 | 162 | test("graphHierarchy() fails with incorrect children", () => { 163 | const build = graphHierarchy(); 164 | // @ts-expect-error invalid arg 165 | expect(() => build(null)).toThrow( 166 | "datum did not have an iterable children field, and no custom children accessor was specified", 167 | ); 168 | // @ts-expect-error invalid arg 169 | expect(() => build({ children: null })).toThrow( 170 | "datum did not have an iterable children field, and no custom children accessor was specified", 171 | ); 172 | }); 173 | -------------------------------------------------------------------------------- /src/graph/json.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { graph, MutGraph } from "."; 3 | import { assert } from "../test-utils"; 4 | import { graphJson } from "./json"; 5 | 6 | interface Json { 7 | (dat: unknown): MutGraph; 8 | } 9 | 10 | test("graphJson() works for empty", () => { 11 | const builder = graphJson(); 12 | const empty = graph(); 13 | const serialized = JSON.stringify(empty); 14 | const deser: unknown = JSON.parse(serialized); 15 | const copy = builder(deser); 16 | expect(copy.nnodes()).toBe(0); 17 | expect(copy.nlinks()).toBe(0); 18 | }); 19 | 20 | test("graphJson() works for simple graph", () => { 21 | const builder = graphJson(); 22 | const grf = graph(); 23 | const root = grf.node(0); 24 | const tail = grf.node(null); 25 | const linka = root.child(tail, 1); 26 | linka.points = [ 27 | [1, 2], 28 | [3, 4], 29 | ]; 30 | const linkb = root.child(tail, "b"); 31 | 32 | const serialized = JSON.stringify(grf); 33 | const deser: unknown = JSON.parse(serialized); 34 | const copy = builder(deser); 35 | 36 | expect(copy.nnodes()).toBe(2); 37 | expect(copy.nlinks()).toBe(2); 38 | const [rut, tul] = copy.topological(); 39 | expect(rut.data).toBe(root.data); 40 | expect(tul.data).toBe(tail.data); 41 | 42 | const [la, lb] = rut.childLinks(); 43 | expect(la.data).toBe(linka.data); 44 | expect(la.points).toEqual([ 45 | [1, 2], 46 | [3, 4], 47 | ]); 48 | expect(lb.data).toBe(linkb.data); 49 | }); 50 | 51 | test("graphJson() serializes individual nodes", () => { 52 | const builder = graphJson(); 53 | const grf = graph(); 54 | const a = grf.node(); 55 | const b = grf.node(); 56 | a.child(b); 57 | grf.node(); 58 | a.x = 2; 59 | 60 | expect(grf.nnodes()).toBe(3); 61 | 62 | // only serialize connected component 63 | const serialized = JSON.stringify(a); 64 | const deser: unknown = JSON.parse(serialized); 65 | const copy = builder(deser); 66 | expect(copy.nnodes()).toBe(2); 67 | // returned the actual node 68 | expect("x" in copy ? copy.x : null).toBe(2); 69 | }); 70 | 71 | class CustomNodeDatum { 72 | constructor(readonly num: number) {} 73 | 74 | toJSON(): unknown { 75 | return this.num + 1; 76 | } 77 | } 78 | 79 | function hydrateNode(data: unknown): CustomNodeDatum { 80 | assert(typeof data === "number"); 81 | return new CustomNodeDatum(data - 1); 82 | } 83 | 84 | class CustomLinkDatum { 85 | constructor(readonly str: string) {} 86 | 87 | toJSON(): unknown { 88 | return `custom: ${this.str}`; 89 | } 90 | } 91 | 92 | function hydrateLink(data: unknown): CustomLinkDatum { 93 | assert(typeof data === "string"); 94 | return new CustomLinkDatum(data.slice(8)); 95 | } 96 | 97 | test("graphJson() works with custom hydration", () => { 98 | const init = graphJson() satisfies Json; 99 | const builder = init.nodeDatum(hydrateNode).linkDatum(hydrateLink); 100 | builder satisfies Json; 101 | expect(builder.nodeDatum() satisfies typeof hydrateNode).toBe(hydrateNode); 102 | expect(builder.linkDatum() satisfies typeof hydrateLink).toBe(hydrateLink); 103 | 104 | const grf = graph(); 105 | const root = grf.node(new CustomNodeDatum(0)); 106 | const tail = grf.node(new CustomNodeDatum(1)); 107 | const linka = root.child(tail, new CustomLinkDatum("a")); 108 | const linkb = root.child(tail, new CustomLinkDatum("b")); 109 | 110 | const serialized = JSON.stringify(grf); 111 | const deser: unknown = JSON.parse(serialized); 112 | const copy = builder(deser); 113 | 114 | expect(copy.nnodes()).toBe(2); 115 | expect(copy.nlinks()).toBe(2); 116 | const [rut, tul] = copy.topological(); 117 | expect(rut.data.num).toBe(root.data.num); 118 | expect(tul.data.num).toBe(tail.data.num); 119 | 120 | const [la, lb] = rut.childLinks(); 121 | expect(la.data.str).toBe(linka.data.str); 122 | expect(lb.data.str).toBe(linkb.data.str); 123 | }); 124 | 125 | test("graphJson() fails passing an arg to graphJson", () => { 126 | // @ts-expect-error no arguments to graphJson 127 | expect(() => graphJson(null)).toThrow("got arguments to graphJson"); 128 | }); 129 | 130 | test("graphJson() fails to parse invalid formats", () => { 131 | const builder = graphJson(); 132 | expect(() => builder(null)).toThrow("was null"); 133 | expect(() => builder({})).toThrow("didn't have 'nodes' and 'links'"); 134 | expect(() => builder({ nodes: null, links: null })).toThrow( 135 | "'nodes' and 'links' weren't arrays", 136 | ); 137 | expect(() => builder({ nodes: [null], links: [] })).toThrow( 138 | "'nodes' and 'links' didn't have the appropriate structure", 139 | ); 140 | expect(() => builder({ nodes: [{ x: true }], links: [] })).toThrow( 141 | "'nodes' and 'links' didn't have the appropriate structure", 142 | ); 143 | expect(() => builder({ nodes: [{ x: "0", y: "0" }], links: [] })).toThrow( 144 | "'nodes' and 'links' didn't have the appropriate structure", 145 | ); 146 | expect(() => builder({ nodes: [{}], links: [{}] })).toThrow( 147 | "'nodes' and 'links' didn't have the appropriate structure", 148 | ); 149 | expect(() => 150 | builder({ 151 | nodes: [{}], 152 | links: [{ source: null, target: null, points: [] }], 153 | }), 154 | ).toThrow("'nodes' and 'links' didn't have the appropriate structure"); 155 | expect(() => 156 | builder({ 157 | nodes: [{}], 158 | links: [{ source: 0, target: 0, points: [null] }], 159 | }), 160 | ).toThrow("'nodes' and 'links' didn't have the appropriate structure"); 161 | }); 162 | -------------------------------------------------------------------------------- /src/graph/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { verifyId } from "./utils"; 3 | 4 | test("verifyId() throws", () => { 5 | // @ts-expect-error wrong type 6 | expect(() => verifyId(0)).toThrow( 7 | `supposed to be type string but got type "number"`, 8 | ); 9 | }); 10 | -------------------------------------------------------------------------------- /src/graph/utils.ts: -------------------------------------------------------------------------------- 1 | import { err } from "../utils"; 2 | 3 | /** 4 | * Verify an ID is a valid ID. 5 | */ 6 | export function verifyId(id: string): string { 7 | if (typeof id !== "string") { 8 | throw err`id is supposed to be type string but got type ${typeof id}`; 9 | } 10 | return id; 11 | } 12 | 13 | /** 14 | * an accessor for getting ids from node data 15 | * 16 | * The accessor must return an appropriate unique string id for given datum. 17 | * This operator will only be called once for each input. 18 | * 19 | * `index` will increment in the order data are processed. 20 | * 21 | * This is used in {@link Stratify#id}, {@link Connect#sourceId}, and 22 | * {@link Connect#targetId}. 23 | */ 24 | export interface Id { 25 | /** 26 | * get node id from a datum 27 | * 28 | * @param datum - the datum to get the id from 29 | * @param index - the index that the data was encountered in 30 | * @returns id - the id corresponding to the node datum 31 | */ 32 | (datum: Datum, index: number): string; 33 | } 34 | -------------------------------------------------------------------------------- /src/grid/lane/greedy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { graphConnect } from "../../graph/connect"; 3 | import { dummy, en, ex, three, trip } from "../../test-graphs"; 4 | import { laneGreedy as greedy } from "./greedy"; 5 | import { crossings, hard, prepare } from "./test-utils"; 6 | 7 | test("greedy() works for triangle", () => { 8 | const nodes = prepare(dummy()); 9 | const layout = greedy(); 10 | layout(nodes); 11 | expect(nodes.map((node) => node.x)).toEqual([0, 1, 0]); 12 | }); 13 | 14 | test("greedy() works for triangle bottom-up", () => { 15 | const nodes = prepare(dummy()); 16 | const layout = greedy().topDown(false); 17 | expect(layout.topDown()).toBe(false); 18 | layout(nodes); 19 | expect(nodes.map((node) => node.x)).toEqual([0, 1, 0]); 20 | }); 21 | 22 | test("greedy() works for ex", () => { 23 | const nodes = prepare(ex()); 24 | const layout = greedy(); 25 | layout(nodes); 26 | expect(nodes.map((node) => node.x)).toEqual([0, 0, 1, 0, 1, 0, 0]); 27 | }); 28 | 29 | test("greedy() compresses single directionally", () => { 30 | const creator = graphConnect(); 31 | const nodes = prepare( 32 | creator([ 33 | ["0", "9"], 34 | ["1", "4"], 35 | ["2", "8"], 36 | ["3", "5"], 37 | // this node could be put in two places, to minimize edge distance, or 38 | // overall width 39 | ["5", "6"], 40 | ["5", "7"], 41 | ]), 42 | ); 43 | const layout = greedy(); 44 | layout(nodes); 45 | expect(nodes.map((node) => node.x)).toEqual([0, 1, 2, 3, 1, 3, 1, 3, 2, 0]); 46 | 47 | // clear history 48 | for (const node of nodes) { 49 | node.ux = undefined; 50 | } 51 | 52 | const uncompressed = layout.compressed(false); 53 | expect(uncompressed.compressed()).toBe(false); 54 | uncompressed(nodes); 55 | expect(nodes.map((node) => node.x)).toEqual([0, 1, 2, 3, 1, 3, 4, 3, 2, 0]); 56 | }); 57 | 58 | test("greedy() bidirectionalizes en", () => { 59 | const nodes = prepare(en()); 60 | const layout = greedy(); 61 | layout(nodes); 62 | expect(nodes.map((node) => node.x)).toEqual([0, 2, 1, 0]); 63 | 64 | // clear history 65 | for (const node of nodes) { 66 | node.ux = undefined; 67 | } 68 | 69 | const bidirec = layout.bidirectional(true); 70 | expect(bidirec.bidirectional()).toBe(true); 71 | bidirec(nodes); 72 | expect(nodes.map((node) => node.x)).toEqual([1, 0, 2, 1]); 73 | }); 74 | 75 | test("greedy() bidirectionalizes three", () => { 76 | const nodes = prepare(three()); 77 | const layout = greedy(); 78 | layout(nodes); 79 | expect(nodes.map((node) => node.x)).toEqual([0, 2, 1, 0, 2]); 80 | 81 | // clear history 82 | for (const node of nodes) { 83 | node.ux = undefined; 84 | } 85 | 86 | const bidirec = layout.bidirectional(true); 87 | bidirec(nodes); 88 | expect(nodes.map((node) => node.x)).toEqual([1, 0, 2, 1, 0]); 89 | }); 90 | 91 | test("greedy() compresses bidirectionally", () => { 92 | const creator = graphConnect(); 93 | const nodes = prepare( 94 | creator([ 95 | ["0", "5"], 96 | ["1", "8"], 97 | ["2", "4"], 98 | ["3", "6"], 99 | // this node could be put in two places, to minimize edge distance, or 100 | // overall width 101 | ["6", "7"], 102 | ["6", "9"], 103 | ]), 104 | ); 105 | const layout = greedy().bidirectional(true); 106 | layout(nodes); 107 | expect(nodes.map((node) => node.x)).toEqual([1, 2, 0, 3, 0, 1, 3, 1, 2, 3]); 108 | 109 | // clear history 110 | for (const node of nodes) { 111 | node.ux = undefined; 112 | } 113 | 114 | const uncompressed = layout.compressed(false); 115 | uncompressed(nodes); 116 | expect(nodes.map((node) => node.x)).toEqual([1, 2, 0, 3, 0, 1, 3, 4, 2, 3]); 117 | }); 118 | 119 | test("greedy() produces layout for bottom-up bidirectional uncompressed", () => { 120 | const creator = graphConnect(); 121 | const nodes = prepare( 122 | creator([ 123 | ["0", "8"], 124 | ["1", "3"], 125 | ["2", "3"], 126 | ["3", "6"], 127 | ["4", "9"], 128 | ["5", "7"], 129 | ]), 130 | ); 131 | const layout = greedy().topDown(false).compressed(false).bidirectional(true); 132 | layout(nodes); 133 | expect(nodes.map((node) => node.x)).toEqual([2, 3, 4, 3, 1, 0, 3, 0, 2, 1]); 134 | }); 135 | 136 | test("greedy() works for unconnected", () => { 137 | const nodes = prepare(trip()); 138 | const layout = greedy(); 139 | layout(nodes); 140 | expect(nodes.map((node) => node.x)).toEqual([0, 0, 0]); 141 | }); 142 | 143 | test("greedy() has crossings for hard case", () => { 144 | const nodes = prepare(hard()); 145 | const layout = greedy(); 146 | layout(nodes); 147 | expect(nodes.map((node) => node.x)).toEqual([0, 3, 2, 1, 0]); 148 | expect(crossings(nodes)).toEqual(2); 149 | }); 150 | 151 | test("greedy() throws for arguments", () => { 152 | // @ts-expect-error no args 153 | expect(() => greedy(null)).toThrow("laneGreedy()"); 154 | }); 155 | -------------------------------------------------------------------------------- /src/grid/lane/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { ccoz, doub, ex, multi, oh, square, zhere } from "../../test-graphs"; 3 | import { laneGreedy } from "./greedy"; 4 | import { laneOpt } from "./opt"; 5 | import { verifyLanes } from "./utils"; 6 | 7 | for (const dat of [doub, ex, square, ccoz, multi, oh, zhere]) { 8 | for (const [name, lane] of [ 9 | ["opt", laneOpt().compressed(false).dist(false)], 10 | ["opt comp dist", laneOpt().compressed(true).dist(true)], 11 | [ 12 | "greedy down", 13 | laneGreedy().topDown(true).compressed(false).bidirectional(false), 14 | ], 15 | [ 16 | "greedy down both", 17 | laneGreedy().topDown(true).compressed(true).bidirectional(true), 18 | ], 19 | [ 20 | "greedy up", 21 | laneGreedy().topDown(false).compressed(false).bidirectional(false), 22 | ], 23 | [ 24 | "greedy up both", 25 | laneGreedy().topDown(false).compressed(true).bidirectional(true), 26 | ], 27 | ] as const) { 28 | test(`invariants apply to ${dat.name} with lane ${name}`, () => { 29 | const dag = dat(); 30 | const nodes = dag.topological(); 31 | for (const [y, node] of nodes.entries()) { 32 | node.y = y; 33 | } 34 | lane(nodes); 35 | const uniq = verifyLanes(nodes, lane); 36 | expect(uniq).toBeGreaterThan(0); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/grid/lane/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A {@link Lane} for assigning nodes in a dag a non-negative 3 | * lane. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { GraphNode } from "../../graph"; 8 | 9 | /** 10 | * An operator for assigning nodes to a lane. 11 | * 12 | * Before calling this operator, all nodes will have their y set to their 13 | * topological order. After, each node should also have an x set to its 14 | * non-negative lane assignment. 15 | * 16 | * @example 17 | * 18 | * It's probably not necessary to implement your own lane operator as the 19 | * defaults should cover most circumstances. To illustrate how you would 20 | * though, the most trivial lane assignment just assigns each node to a unique 21 | * lane: 22 | * 23 | * ```ts 24 | * function trivialLane(ordered: readonly GraphNode[]): void { 25 | * for (const [i, node] in ordered.entries()) { 26 | * node.x = i; 27 | * } 28 | * } 29 | * ``` 30 | */ 31 | export interface Lane { 32 | /** 33 | * assign lanes to ordered nodes 34 | * 35 | * @param ordered - the nodes in to assign lanes to in order from top to 36 | * bottom 37 | */ 38 | (ordered: readonly GraphNode[]): void; 39 | } 40 | -------------------------------------------------------------------------------- /src/grid/lane/opt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { graphConnect } from "../../graph/connect"; 3 | import { doub, dummy, line, single, zhere } from "../../test-graphs"; 4 | import { laneOpt } from "./opt"; 5 | import { crossings, hard, prepare } from "./test-utils"; 6 | 7 | test("laneOpt() works for single", () => { 8 | const nodes = prepare(single()); 9 | for (const compressed of [false, true]) { 10 | for (const dist of [false, true]) { 11 | for (const check of ["fast", "slow", "oom"] as const) { 12 | const layout = laneOpt().compressed(compressed).dist(dist).check(check); 13 | layout(nodes); 14 | expect(nodes.map((node) => node.x)).toEqual([0]); 15 | expect(crossings(nodes)).toEqual(0); 16 | } 17 | } 18 | } 19 | }); 20 | 21 | test("laneOpt() works for line", () => { 22 | const nodes = prepare(line()); 23 | for (const compressed of [false, true]) { 24 | for (const dist of [false, true]) { 25 | const layout = laneOpt().compressed(compressed).dist(dist); 26 | layout(nodes); 27 | expect(nodes.map((node) => node.x)).toEqual([0, 0]); 28 | expect(crossings(nodes)).toEqual(0); 29 | } 30 | } 31 | }); 32 | 33 | test("laneOpt() works for double unconnected", () => { 34 | const nodes = prepare(doub()); 35 | for (const compressed of [false, true]) { 36 | for (const dist of [false, true]) { 37 | const layout = laneOpt().compressed(compressed).dist(dist); 38 | layout(nodes); 39 | expect(nodes.map((node) => node.x)).toEqual([0, 0]); 40 | expect(crossings(nodes)).toEqual(0); 41 | } 42 | } 43 | }); 44 | 45 | test("laneOpt() works for triangle", () => { 46 | const nodes = prepare(dummy()); 47 | const layout = laneOpt(); 48 | layout(nodes); 49 | expect(nodes.map((node) => node.x)).toEqual([0, 1, 0]); 50 | expect(crossings(nodes)).toEqual(0); 51 | }); 52 | 53 | test("laneOpt() works when crossings are unavoidable", () => { 54 | const nodes = prepare(zhere()); 55 | const layout = laneOpt(); 56 | layout(nodes); 57 | expect(crossings(nodes)).toEqual(2); 58 | }); 59 | 60 | test("laneOpt() works where greedy fails", () => { 61 | // all greedy assignment of this produce two crossings 62 | const nodes = prepare(hard()); 63 | const layout = laneOpt(); 64 | layout(nodes); 65 | expect(crossings(nodes)).toEqual(0); 66 | }); 67 | 68 | test("laneOpt() works for compressed", () => { 69 | // we can reduce the width by allowing a crossing 70 | const create = graphConnect(); 71 | const dag = create([ 72 | ["0", "1"], 73 | ["0", "2"], 74 | ["0", "5"], 75 | ["0", "6"], 76 | ["1", "5"], 77 | ["1", "6"], 78 | ["2", "3"], 79 | ["2", "4"], 80 | ]); 81 | const nodes = prepare(dag); 82 | 83 | const layout = laneOpt(); 84 | expect(layout.compressed()).toBe(false); 85 | layout(nodes); 86 | expect(crossings(nodes)).toEqual(0); 87 | expect(Math.max(...nodes.map((node) => node.x))).toEqual(4); 88 | 89 | const comp = layout.compressed(true); 90 | expect(comp.compressed()).toBe(true); 91 | comp(nodes); 92 | expect(Math.max(...nodes.map((node) => node.x))).toEqual(3); 93 | expect(crossings(nodes)).toEqual(1); 94 | }); 95 | 96 | test("laneOpt() works for compressed edge case", () => { 97 | // he we need to test compression for a specific graph where a node without 98 | // parents is allowed to slot in 99 | const create = graphConnect(); 100 | const dag = create([ 101 | ["0", "1"], 102 | ["2", "3"], 103 | ]); 104 | const nodes = prepare(dag); 105 | 106 | const layout = laneOpt().compressed(true); 107 | layout(nodes); 108 | expect(nodes.map((node) => node.x)).toEqual([0, 0, 0, 0]); 109 | }); 110 | 111 | test("laneOpt() dist compacts slightly", () => { 112 | // This is a fragile test, because the minimum distance version will share 113 | // the same optima without it, but it's not likely, so we verify that's the 114 | // case 115 | const create = graphConnect(); 116 | const dag = create([ 117 | ["0", "1"], 118 | ["0", "2"], 119 | ["0", "5"], 120 | ["0", "6"], 121 | ["1", "6"], 122 | ["2", "3"], 123 | ["2", "4"], 124 | ]); 125 | const nodes = prepare(dag); 126 | 127 | const layout = laneOpt(); 128 | expect(layout.dist()).toBe(true); 129 | layout(nodes); 130 | expect(crossings(nodes)).toEqual(0); 131 | expect(Math.max(...nodes.map((node) => node.x))).toEqual(3); 132 | 133 | const dist = layout.dist(false); 134 | expect(dist.dist()).toBe(false); 135 | dist(nodes); 136 | expect(crossings(nodes)).toEqual(0); 137 | expect(Math.max(...nodes.map((node) => node.x))).toBeGreaterThan(3); 138 | }); 139 | 140 | test("laneOpt() throws for large graphs", () => { 141 | const create = graphConnect(); 142 | const ids: [string, string][] = []; 143 | for (let i = 0; i < 18; ++i) { 144 | for (let j = 0; j < i; ++j) { 145 | ids.push([`${j}`, `${i}`]); 146 | } 147 | } 148 | const dag = create(ids); 149 | const nodes = prepare(dag); 150 | 151 | const layout = laneOpt(); 152 | expect(layout.check()).toBe("fast"); 153 | expect(() => layout(nodes)).toThrow(`"slow"`); 154 | }); 155 | 156 | test("laneOpt() throws for very large graphs", () => { 157 | const create = graphConnect(); 158 | const ids: [string, string][] = []; 159 | for (let i = 0; i < 20; ++i) { 160 | for (let j = 0; j < i; ++j) { 161 | ids.push([`${j}`, `${i}`]); 162 | } 163 | } 164 | const dag = create(ids); 165 | const nodes = prepare(dag); 166 | 167 | const layout = laneOpt().check("slow"); 168 | expect(layout.check()).toBe("slow"); 169 | expect(() => layout(nodes)).toThrow(`"oom"`); 170 | }); 171 | 172 | test("laneOpt() throws for arguments", () => { 173 | // @ts-expect-error no args 174 | expect(() => laneOpt(null)).toThrow("laneOpt"); 175 | }); 176 | -------------------------------------------------------------------------------- /src/grid/lane/test-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | * @packageDocumentation 4 | */ 5 | import { Graph, GraphNode } from "../../graph"; 6 | import { graphConnect } from "../../graph/connect"; 7 | import { ConnectGraph } from "../../test-graphs"; 8 | 9 | /** a dag where greedy doesn't minimize crossings */ 10 | export function hard(): ConnectGraph { 11 | const build = graphConnect(); 12 | return build([ 13 | ["0", "1"], 14 | ["0", "2"], 15 | ["0", "3"], 16 | ["0", "4"], 17 | ["1", "4"], 18 | ]); 19 | } 20 | 21 | /** puts nodes in numeric id order, verifying it's also topological */ 22 | export function prepare(grf: Graph): GraphNode[] { 23 | const nodes: GraphNode[] = Array>( 24 | grf.nnodes(), 25 | ); 26 | for (const node of grf.nodes()) { 27 | const y = parseInt(node.data); 28 | node.y = y; 29 | nodes[y] = node; 30 | } 31 | return nodes; 32 | } 33 | 34 | /** compute the number of edge crossings */ 35 | export function crossings(ordered: GraphNode[]): number { 36 | let crossings = 0; 37 | const parentIndex = new Map(); 38 | for (const [ind, node] of ordered.entries()) { 39 | const topIndex = parentIndex.get(node); 40 | if (topIndex !== undefined) { 41 | // have the potential for crossings 42 | for (const above of ordered.slice(topIndex + 1, ind)) { 43 | for (const child of above.children()) { 44 | if ( 45 | (above.x > node.x && child.x < node.x) || 46 | (above.x < node.x && child.x > node.x) 47 | ) { 48 | crossings++; 49 | } 50 | } 51 | } 52 | } 53 | 54 | // update parent index 55 | for (const child of node.children()) { 56 | if (!parentIndex.has(child)) { 57 | parentIndex.set(child, ind); 58 | } 59 | } 60 | } 61 | return crossings; 62 | } 63 | -------------------------------------------------------------------------------- /src/grid/lane/utils.ts: -------------------------------------------------------------------------------- 1 | import { setEqual } from "../../collections"; 2 | import { GraphNode } from "../../graph"; 3 | import { chain, filter, map, slice } from "../../iters"; 4 | import { berr } from "../../utils"; 5 | import { Lane } from "./index"; 6 | 7 | /** the effective children for grid layouts */ 8 | export function gridChildren(node: GraphNode): Set { 9 | return new Set( 10 | filter(chain(node.children(), node.parents()), (other) => other.y > node.y), 11 | ); 12 | } 13 | 14 | /** 15 | * Verify that nodes were assigned valid lanes 16 | */ 17 | export function verifyLanes(ordered: GraphNode[], lane: Lane): number { 18 | for (const node of ordered) { 19 | if (node.ux === undefined) { 20 | throw berr`lane ${lane} didn't assign an x to every node`; 21 | } else if (node.x < 0) { 22 | throw berr`lane ${lane} assigned an x less than 0: ${node.x}`; 23 | } 24 | } 25 | 26 | const uniqueExes = new Set(ordered.map((node) => node.x)); 27 | if (!setEqual(uniqueExes, new Set(map(uniqueExes, (_, i) => i)))) { 28 | const exStr = [...uniqueExes].sort((a, b) => a - b).join(", "); 29 | throw berr`lane ${lane} didn't assign increasing positive integers for x coordinates: ${exStr}`; 30 | } 31 | 32 | const parentIndex = new Map(); 33 | for (const [ind, node] of ordered.entries()) { 34 | // test that no nodes overlap with edges 35 | const topIndex = parentIndex.get(node); 36 | if (topIndex !== undefined) { 37 | for (const above of slice(ordered, topIndex + 1, ind)) { 38 | if (above.x === node.x) { 39 | throw berr`lane ${lane} assigned nodes to an overlapping lane: ${node.x}`; 40 | } 41 | } 42 | } 43 | 44 | // update parent index 45 | for (const child of node.children()) { 46 | if (!parentIndex.has(child)) { 47 | parentIndex.set(child, ind); 48 | } 49 | } 50 | } 51 | 52 | return uniqueExes.size; 53 | } 54 | -------------------------------------------------------------------------------- /src/iters.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { 3 | bigrams, 4 | chain, 5 | entries, 6 | every, 7 | flatMap, 8 | length, 9 | map, 10 | reduce, 11 | slice, 12 | } from "./iters"; 13 | 14 | test("entries()", () => { 15 | expect([...entries([])]).toEqual([]); 16 | expect([...entries([1, 2, 3])]).toEqual([ 17 | [0, 1], 18 | [1, 2], 19 | [2, 3], 20 | ]); 21 | }); 22 | 23 | test("every()", () => { 24 | expect(every([1, 2, 3], (v) => v > 0)).toBeTruthy(); 25 | expect(every([1, -2, 3], (v) => v > 0)).toBeFalsy(); 26 | }); 27 | 28 | test("flatMap()", () => { 29 | expect([...flatMap([1, 2], (v, i) => [v, i])]).toEqual([1, 0, 2, 1]); 30 | expect([...flatMap([1, 2], (v) => [v])]).toEqual([1, 2]); 31 | }); 32 | 33 | test("map()", () => { 34 | expect([...map([1, 2, 3], (v) => v.toString())]).toEqual(["1", "2", "3"]); 35 | expect([...map([], (v) => v.toString())]).toEqual([]); 36 | }); 37 | 38 | test("reduce()", () => { 39 | expect(reduce([1, 2, 3], (a, b) => a + b, 0)).toBeCloseTo(6); 40 | expect(reduce([1, 2, 3], (a, b) => `${a}${b}`, "")).toBe("123"); 41 | }); 42 | 43 | test("slice()", () => { 44 | expect([...slice([1, 2, 3])]).toEqual([1, 2, 3]); 45 | expect([...slice([1, 2, 3], 1)]).toEqual([2, 3]); 46 | expect([...slice([1, 2, 3], 1, 2)]).toEqual([2]); 47 | expect([...slice([1, 2, 3], 0, undefined, 2)]).toEqual([1, 3]); 48 | expect([...slice([1, 2, 3], 2, -1, -1)]).toEqual([3, 2, 1]); 49 | expect([...slice([1, 2, 3], 2, 0, -1)]).toEqual([3, 2]); 50 | expect(() => slice([], 0, 0, 0)).toThrow("zero stride"); 51 | }); 52 | 53 | test("chain()", () => { 54 | expect([...chain([1, 2, 3], [4, 5], [6])]).toEqual([1, 2, 3, 4, 5, 6]); 55 | }); 56 | 57 | test("bigrams()", () => { 58 | expect([...bigrams([1, 2, 3, 4])]).toEqual([ 59 | [1, 2], 60 | [2, 3], 61 | [3, 4], 62 | ]); 63 | }); 64 | 65 | test("length()", () => { 66 | expect(length([])).toBe(0); 67 | expect(length([1])).toBe(1); 68 | expect(length(chain([1, 2]))).toBe(2); 69 | }); 70 | -------------------------------------------------------------------------------- /src/iters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stopgap for esnext iterable features 3 | * 4 | * @internal 5 | * @packageDocumentation 6 | */ 7 | import { err } from "./utils"; 8 | 9 | /** iterable callback that maps a value into another */ 10 | export interface MapCallback { 11 | (element: T, index: number): S; 12 | } 13 | 14 | /** iterable callback that maps a value into another */ 15 | export interface GuardCallback { 16 | (element: T, index: number): element is R; 17 | } 18 | 19 | /** reduce callback */ 20 | export interface ReduceCallback { 21 | (accumulator: S, currentValue: T, index: number): S; 22 | } 23 | 24 | /** filter guard callback */ 25 | export interface FilterGuardCallback { 26 | (element: T, index: number): element is S; 27 | } 28 | 29 | /** elements with their zero based index */ 30 | export function* entries(iter: Iterable): IterableIterator<[number, T]> { 31 | let index = 0; 32 | for (const element of iter) { 33 | yield [index++, element]; 34 | } 35 | } 36 | 37 | /** iterable flat map */ 38 | export function* flatMap( 39 | iter: Iterable, 40 | callback: MapCallback>, 41 | ): IterableIterator { 42 | for (const [index, element] of entries(iter)) { 43 | yield* callback(element, index); 44 | } 45 | } 46 | 47 | /** iterable reduce */ 48 | export function reduce( 49 | iter: Iterable, 50 | callback: ReduceCallback, 51 | initialValue: S, 52 | ): S { 53 | let accumulator = initialValue; 54 | for (const [index, element] of entries(iter)) { 55 | accumulator = callback(accumulator, element, index); 56 | } 57 | return accumulator; 58 | } 59 | 60 | /** iterable map */ 61 | export function* map( 62 | iter: Iterable, 63 | callback: MapCallback, 64 | ): IterableIterator { 65 | for (const [index, element] of entries(iter)) { 66 | yield callback(element, index); 67 | } 68 | } 69 | 70 | /** guard iterable filter */ 71 | export function filter( 72 | iter: Iterable, 73 | callback: FilterGuardCallback, 74 | ): IterableIterator; 75 | /** generic iterable filter */ 76 | export function filter( 77 | iter: Iterable, 78 | callback: MapCallback, 79 | ): IterableIterator; 80 | export function* filter( 81 | iter: Iterable, 82 | callback: MapCallback, 83 | ): IterableIterator { 84 | for (const [index, element] of entries(iter)) { 85 | if (callback(element, index)) { 86 | yield element; 87 | } 88 | } 89 | } 90 | 91 | /** iterable some */ 92 | export function some( 93 | iter: Iterable, 94 | callback: MapCallback, 95 | ): boolean { 96 | for (const [index, element] of entries(iter)) { 97 | if (callback(element, index)) { 98 | return true; 99 | } 100 | } 101 | return false; 102 | } 103 | 104 | /** iterable every */ 105 | export function every( 106 | iter: Iterable, 107 | callback: GuardCallback, 108 | ): iter is Iterable; 109 | export function every( 110 | iter: Iterable, 111 | callback: MapCallback, 112 | ): boolean; 113 | export function every( 114 | iter: Iterable, 115 | callback: MapCallback, 116 | ): boolean { 117 | return !some(iter, (e, i) => !callback(e, i)); 118 | } 119 | 120 | /** iterable length */ 121 | export function length(iter: Iterable): number { 122 | let count = 0; 123 | for (const _ of iter) ++count; 124 | return count; 125 | } 126 | 127 | function* slicePos( 128 | arr: readonly T[], 129 | frm: number, 130 | to: number, 131 | stride: number, 132 | ): IterableIterator { 133 | const limit = Math.min(to, arr.length); 134 | for (let i = frm; i < limit; i += stride) { 135 | yield arr[i]; 136 | } 137 | } 138 | 139 | function* sliceNeg( 140 | arr: readonly T[], 141 | frm: number, 142 | to: number, 143 | stride: number, 144 | ): IterableIterator { 145 | const limit = Math.max(to, -1); 146 | for (let i = frm; i > limit; i += stride) { 147 | yield arr[i]; 148 | } 149 | } 150 | 151 | /** iterable slice of an array */ 152 | export function slice( 153 | arr: readonly T[], 154 | frm: number = 0, 155 | to: number = arr.length, 156 | stride: number = 1, 157 | ): IterableIterator { 158 | if (stride > 0) { 159 | return slicePos(arr, frm, to, stride); 160 | } else if (stride < 0) { 161 | return sliceNeg(arr, frm, to, stride); 162 | } else { 163 | throw err`can't slice with zero stride`; 164 | } 165 | } 166 | 167 | /** iterable reverse of an array */ 168 | export function reverse(arr: readonly T[]): IterableIterator { 169 | return slice(arr, arr.length - 1, -1, -1); 170 | } 171 | 172 | /** chain several iterables */ 173 | export function* chain(...iters: Iterable[]): IterableIterator { 174 | for (const iter of iters) { 175 | yield* iter; 176 | } 177 | } 178 | 179 | /** iterate over bigrams of an iterable */ 180 | export function* bigrams(iterable: Iterable): IterableIterator<[T, T]> { 181 | const iter: Iterator = iterable[Symbol.iterator](); 182 | const first = iter.next(); 183 | if (!first.done) { 184 | let last = first.value; 185 | let next; 186 | while (!(next = iter.next()).done) { 187 | yield [last, next.value]; 188 | last = next.value; 189 | } 190 | } 191 | } 192 | 193 | /** return the first element of an iterable */ 194 | export function first(iterable: Iterable): T | undefined { 195 | for (const item of iterable) { 196 | return item; 197 | } 198 | } 199 | 200 | /** return if something is iterable */ 201 | export function isIterable(obj: unknown): obj is Iterable { 202 | return ( 203 | typeof obj === "object" && 204 | obj !== null && 205 | Symbol.iterator in obj && 206 | typeof obj[Symbol.iterator] === "function" 207 | ); 208 | } 209 | -------------------------------------------------------------------------------- /src/layout.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { graph } from "./graph"; 3 | import { cachedNodeSize, splitNodeSize } from "./layout"; 4 | 5 | test("cachedNodeSize() const", () => { 6 | const node = graph().node(); 7 | const cached = cachedNodeSize([2, 1]); 8 | expect(cached(node)).toEqual([2, 1]); 9 | expect(cached(node)).toEqual([2, 1]); 10 | }); 11 | 12 | test("cachedNodeSize() const error", () => { 13 | expect(() => cachedNodeSize([0, 1])).toThrow( 14 | "all node sizes must be positive", 15 | ); 16 | }); 17 | 18 | test("cachedNodeSize() callable", () => { 19 | const node = graph().node(); 20 | const calls: [number] = [0]; 21 | const cached = cachedNodeSize(() => { 22 | calls[0] += 1; 23 | return [2, 1]; 24 | }); 25 | expect(cached(node)).toEqual([2, 1]); 26 | expect(cached(node)).toEqual([2, 1]); 27 | expect(calls[0]).toBe(1); 28 | }); 29 | 30 | test("cachedNodeSize() callable raises", () => { 31 | const node = graph().node(); 32 | const cached = cachedNodeSize(() => [0, 1]); 33 | expect(() => cached(node)).toThrow("all node sizes must be positive"); 34 | }); 35 | 36 | test("splitNodeSize() const", () => { 37 | const node = graph().node(); 38 | const [x, y] = splitNodeSize([0, 1]); 39 | expect(x(node)).toBe(0); 40 | expect(y(node)).toBe(1); 41 | }); 42 | 43 | test("splitNodeSize() callable", () => { 44 | const node = graph().node(); 45 | const [x, y] = splitNodeSize(() => [0, 1]); 46 | expect(x(node)).toBe(0); 47 | expect(y(node)).toBe(1); 48 | }); 49 | -------------------------------------------------------------------------------- /src/layout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities and types common to all layouts 3 | * 4 | * @packageDocumentation 5 | */ 6 | import { GraphNode } from "./graph"; 7 | import { err } from "./utils"; 8 | 9 | /** 10 | * A strictly callable {@link NodeSize} 11 | */ 12 | export interface CallableNodeSize { 13 | /** 14 | * compute the node size of a graph node 15 | * 16 | * @param node - the node to get the size of 17 | * @returns dimensions - the width and height of `node` 18 | */ 19 | (node: GraphNode): readonly [number, number]; 20 | } 21 | 22 | /** 23 | * an accessor for computing the size of a node in the layout 24 | * 25 | * A node size can either be a constant tuple of `[width, height]`, or a 26 | * callable, that takes a node and returns the width and height for that node. 27 | * 28 | * @remarks 29 | * 30 | * Due to the way that d3-dag (and typescript) infers types, a constant 31 | * function (e.g. `() => [1, 1]`) may infer data types as `never` producing 32 | * errors down the line. In these cases, you'll want to use a constant tuple. 33 | * 34 | * @example 35 | * 36 | * This example sets the node width to the length of the name. In most cases 37 | * you'd probably want to actually render the text and measure the size, rather 38 | * than assume a fixed width, but this example is easier to understand. 39 | * 40 | * ```ts 41 | * function widthSize({ data }: GraphNode<{ name: string }>): [number, number] { 42 | * return [data.name.length, 1]; 43 | * } 44 | * ``` 45 | */ 46 | export type NodeSize = 47 | | readonly [number, number] 48 | | CallableNodeSize; 49 | 50 | /** An accessor for computing the length of a node */ 51 | export interface NodeLength { 52 | /** 53 | * compute the length (width or height) of a graph node 54 | * 55 | * @param node - the node to get the length of 56 | * @returns length - the width or height of `node` 57 | */ 58 | (node: GraphNode): number; 59 | } 60 | 61 | /** 62 | * cache a {@link NodeSize} so it is called at most once for every node 63 | */ 64 | export function cachedNodeSize( 65 | nodeSize: NodeSize, 66 | ): CallableNodeSize { 67 | if (typeof nodeSize !== "function") { 68 | const [x, y] = nodeSize; 69 | if (x <= 0 || y <= 0) { 70 | throw err`all node sizes must be positive, but got width ${x} and height ${y}`; 71 | } 72 | return () => [x, y]; 73 | } else { 74 | const cache = new Map, readonly [number, number]>(); 75 | 76 | const cached = (node: GraphNode): readonly [number, number] => { 77 | let val = cache.get(node); 78 | if (val === undefined) { 79 | val = nodeSize(node); 80 | const [width, height] = val; 81 | if (width <= 0 || height <= 0) { 82 | throw err`all node sizes must be positive, but got width ${width} and height ${height} for node with data: ${node.data}; make sure the callback passed to \`sugiyama().nodeSize(...)\` is doing that`; 83 | } 84 | cache.set(node, val); 85 | } 86 | return val; 87 | }; 88 | 89 | return cached; 90 | } 91 | } 92 | 93 | /** 94 | * split a {@link NodeSize} into x and y {@link NodeLength}s 95 | * 96 | * This allows you to split a NodeSize into independent x and y accessors. 97 | * 98 | * The only real reason to use this would be to run the steps of 99 | * {@link sugiyama} independently. 100 | */ 101 | export function splitNodeSize( 102 | nodeSize: NodeSize, 103 | ): readonly [NodeLength, NodeLength] { 104 | if (typeof nodeSize !== "function") { 105 | const [x, y] = nodeSize; 106 | return [() => x, () => y]; 107 | } else { 108 | const callable = nodeSize; 109 | return [(node) => callable(node)[0], (node) => callable(node)[1]]; 110 | } 111 | } 112 | 113 | /** the height and width returned after laying out a graph */ 114 | export interface LayoutResult { 115 | /** the total weight after layout */ 116 | width: number; 117 | /** the total height after layout */ 118 | height: number; 119 | } 120 | 121 | /** 122 | * how to handle optimally solving certain layouts 123 | * 124 | * - `"fast"` - raise an exception if the layout can't be done quickly 125 | * - `"slow"` - raise an exception if the layout might oom 126 | * - `"oom"` - never raise an exception, use at your own risk 127 | */ 128 | export type OptChecking = "fast" | "slow" | "oom"; 129 | -------------------------------------------------------------------------------- /src/simplex.ts: -------------------------------------------------------------------------------- 1 | import { Constraint, Solve, Variable } from "javascript-lp-solver"; 2 | import { ierr } from "./utils"; 3 | export type { Constraint, Variable }; 4 | 5 | /** solve an lp with a better interface */ 6 | export function solve( 7 | optimize: string, 8 | opType: "max" | "min", 9 | variables: Record, 10 | constraints: Record, 11 | ints: Record = {}, 12 | ): Record { 13 | // NOTE bundling sets `this` to undefined, and we need it to be settable 14 | const { feasible, ...assignment } = Solve.call( 15 | {}, 16 | { 17 | optimize, 18 | opType, 19 | constraints, 20 | variables, 21 | ints, 22 | }, 23 | ); 24 | if (!feasible) { 25 | throw ierr`could not find a feasible simplex solution`; 26 | } 27 | return assignment; 28 | } 29 | -------------------------------------------------------------------------------- /src/sugiyama/coord/center.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { createLayers, nodeSep } from "../test-utils"; 3 | import { coordCenter as center } from "./center"; 4 | 5 | test("center() works for square like layout", () => { 6 | const layers = createLayers([[[0, 1]], [[0], [0]], [[]]]); 7 | const [[head], [left, right], [tail]] = layers; 8 | center()(layers, nodeSep); 9 | 10 | expect(head.x).toBeCloseTo(1.0, 7); 11 | expect(left.x).toBeCloseTo(0.5, 7); 12 | expect(right.x).toBeCloseTo(1.5, 7); 13 | expect(tail.x).toBeCloseTo(1.0, 7); 14 | }); 15 | 16 | test("center() fails passing an arg to constructor", () => { 17 | expect(() => center(null as never)).toThrow("got arguments to coordCenter"); 18 | }); 19 | 20 | test("center() throws for zero width", () => { 21 | const layers = createLayers([[[]]]); 22 | expect(() => center()(layers, () => 0)).toThrow( 23 | "must assign nonzero width to at least one node", 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /src/sugiyama/coord/center.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The {@link CoordCenter} centers all of the nodes as compactly as 3 | * possible. It produces generally poor layouts, but is very fast. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { Coord } from "."; 8 | import { err } from "../../utils"; 9 | import { SugiNode, SugiSeparation } from "../sugify"; 10 | 11 | /** 12 | * A {@link Coord} that spaces every node out by node size, and then centers 13 | * them. 14 | * 15 | * This is a very fast operator, but doesn't produce very pleasing layouts. 16 | * 17 | * Create with {@link coordCenter}. 18 | */ 19 | export interface CoordCenter extends Coord { 20 | /** @internal flag indicating that this is built in to d3dag and shouldn't error in specific instances */ 21 | readonly d3dagBuiltin: true; 22 | } 23 | 24 | /** 25 | * Create a {@link CoordCenter} 26 | */ 27 | export function coordCenter(...args: never[]): CoordCenter { 28 | if (args.length) { 29 | throw err`got arguments to coordCenter(${args}); you probably forgot to construct coordCenter before passing to coord: \`sugiyama().coord(coordCenter())\`, note the trailing "()"`; 30 | } 31 | 32 | function coordCenter( 33 | layers: SugiNode[][], 34 | sep: SugiSeparation, 35 | ): number { 36 | const widths = layers.map((layer) => { 37 | let width = 0; 38 | let last; 39 | for (const node of layer) { 40 | width += sep(last, node); 41 | node.x = width; 42 | last = node; 43 | } 44 | width += sep(last, undefined); 45 | return width; 46 | }); 47 | const maxWidth = Math.max(...widths); 48 | if (maxWidth <= 0) { 49 | throw err`must assign nonzero width to at least one node; double check the callback passed to \`sugiyama().nodeSize(...)\``; 50 | } 51 | for (const [i, layer] of layers.entries()) { 52 | const width = widths[i]; 53 | const offset = (maxWidth - width) / 2; 54 | for (const node of layer) { 55 | node.x += offset; 56 | } 57 | } 58 | 59 | return maxWidth; 60 | } 61 | 62 | coordCenter.d3dagBuiltin = true as const; 63 | 64 | return coordCenter; 65 | } 66 | -------------------------------------------------------------------------------- /src/sugiyama/coord/greedy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { bigrams } from "../../iters"; 3 | import { createLayers, nodeSep } from "../test-utils"; 4 | import { coordGreedy } from "./greedy"; 5 | 6 | test("coordGreedy() works for N", () => { 7 | // degree matters 8 | const layers = createLayers([ 9 | [[0, 1], [1]], 10 | [[], []], 11 | ]); 12 | const [[topLeft, topRight], [bottomLeft, bottomRight]] = layers; 13 | const coord = coordGreedy(); 14 | coord(layers, nodeSep); 15 | 16 | expect(topLeft.x).toBeCloseTo(0.75); 17 | expect(topRight.x).toBeCloseTo(1.75); 18 | expect(bottomLeft.x).toBeCloseTo(0.5); 19 | expect(bottomRight.x).toBeCloseTo(1.5); 20 | }); 21 | 22 | test("coordGreedy() works for carat", () => { 23 | // index fallback if not degree 24 | const layers = createLayers([[[0, 1]], [[], []]]); 25 | const [[head], [left, right]] = layers; 26 | const coord = coordGreedy(); 27 | coord(layers, nodeSep); 28 | 29 | expect(head.x).toBeCloseTo(1); 30 | expect(left.x).toBeCloseTo(0.5); 31 | expect(right.x).toBeCloseTo(1.5); 32 | }); 33 | 34 | test("coordGreedy() works for triangle", () => { 35 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 36 | const [[one], [two, dummy], [three]] = layers; 37 | const coord = coordGreedy(); 38 | coord(layers, nodeSep); 39 | 40 | expect(one.x).toBeCloseTo(1); 41 | expect(two.x).toBeCloseTo(0.5); 42 | expect(dummy.x).toBeCloseTo(1.5); 43 | expect(three.x).toBeCloseTo(1); 44 | }); 45 | 46 | test("coordGreedy() straightens long edge", () => { 47 | const layers = createLayers([[[0, 1]], [[0], 1], [[0], 0], [[]]]); 48 | const [[one], [two, topDummy], [three, bottomDummy], [four]] = layers; 49 | const coord = coordGreedy(); 50 | coord(layers, nodeSep); 51 | 52 | expect(one.x).toBeCloseTo(1); 53 | expect(two.x).toBeCloseTo(0.5); 54 | expect(topDummy.x).toBeCloseTo(1.5); 55 | expect(three.x).toBeCloseTo(0.5); 56 | expect(bottomDummy.x).toBeCloseTo(1.5); 57 | expect(four.x).toBeCloseTo(1); 58 | }); 59 | 60 | test("coordGreedy() works with simple compact dag", () => { 61 | const layers = createLayers([ 62 | [[1], [0], 2n, [3]], 63 | [[], [], [], []], 64 | ]); 65 | const layout = coordGreedy(); 66 | const width = layout(layers, nodeSep); 67 | 68 | expect(width).toBeCloseTo(4.75); 69 | for (const layer of layers) { 70 | for (const [left, right] of bigrams(layer)) { 71 | const gap = nodeSep(left, right) - 1e-3; 72 | expect(right.x - left.x).toBeGreaterThanOrEqual(gap); 73 | } 74 | } 75 | }); 76 | 77 | test("coordGreedy() works with compact dag", () => { 78 | // r 79 | // / \ 80 | // / # 81 | // l c r 82 | // \ # 83 | // \ / 84 | // t 85 | const layers = createLayers([ 86 | [0n], 87 | [[0, 1]], 88 | [0, 2n], 89 | [0n, 1n, 2n], 90 | [[0], [], 1n], 91 | [0, [0]], 92 | [0n], 93 | [[]], 94 | ]); 95 | const layout = coordGreedy(); 96 | const width = layout(layers, nodeSep); 97 | 98 | expect(width).toBeCloseTo(3.0625); 99 | for (const layer of layers) { 100 | for (const [left, right] of bigrams(layer)) { 101 | const gap = nodeSep(left, right) - 1e-3; 102 | expect(right.x - left.x).toBeGreaterThanOrEqual(gap); 103 | } 104 | } 105 | }); 106 | 107 | test("coordGreedy() fails passing an arg to constructor", () => { 108 | // @ts-expect-error no args 109 | expect(() => coordGreedy(null)).toThrow("got arguments to coordGreedy"); 110 | }); 111 | 112 | test("coordGreedy() throws for zero width", () => { 113 | const layers = createLayers([[[]]]); 114 | expect(() => coordGreedy()(layers, () => 0)).toThrow( 115 | "must assign nonzero width to at least one node", 116 | ); 117 | }); 118 | -------------------------------------------------------------------------------- /src/sugiyama/coord/greedy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The {@link CoordGreedy} assigns nodes close to the mean of their parents, 3 | * then spreads them out. 4 | * 5 | * @packageDocumentation 6 | */ 7 | // TODO add assignment like mean that skips dummy nodes as that seems like 8 | // better behavior 9 | import { median } from "d3-array"; 10 | import { Coord } from "."; 11 | import { entries, map, slice } from "../../iters"; 12 | import { err } from "../../utils"; 13 | import { SugiNode, SugiSeparation } from "../sugify"; 14 | 15 | /** 16 | * a {@link Coord} that tries to place nodes close to their parents 17 | * 18 | * Nodes that can't be placed at the mean of their parents' location, will be 19 | * spaced out with their priority equal to their degree. 20 | * 21 | * Create with {@link coordGreedy}. 22 | */ 23 | export interface CoordGreedy extends Coord { 24 | /** @internal flag indicating that this is built in to d3dag and shouldn't error in specific instances */ 25 | readonly d3dagBuiltin: true; 26 | } 27 | 28 | /** assign nodes based on the median of their ancestor exes */ 29 | function assign( 30 | layer: readonly SugiNode[], 31 | ancestors: (node: SugiNode) => Iterable, 32 | ): void { 33 | for (const node of layer) { 34 | const x = median([...map(ancestors(node), ({ x }) => x)]); 35 | if (x !== undefined) { 36 | node.x = x; 37 | } 38 | } 39 | } 40 | 41 | /** same as assign, but only for links between two dummy nodes */ 42 | function straighten( 43 | layer: readonly SugiNode[], 44 | ancestors: (node: SugiNode) => Iterable, 45 | ): void { 46 | for (const node of layer) { 47 | const [ancestor] = ancestors(node); 48 | if (node.data.role === "link" && ancestor.data.role === "link") { 49 | node.x = ancestor.x; 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * space apart nodes 56 | * 57 | * This spaces apart nodes using the separation function. It goes forward and 58 | * backward and then sets the x coordinate to be the midway point. 59 | */ 60 | function space( 61 | layer: readonly SugiNode[], 62 | sep: SugiSeparation, 63 | ): void { 64 | let last = layer[layer.length - 1]; 65 | let lastx = last.x; 66 | const after = [lastx]; 67 | for (const sugi of slice(layer, layer.length - 2, -1, -1)) { 68 | const next = Math.min(sugi.x, lastx - sep(last, sugi)); 69 | after.push(next); 70 | last = sugi; 71 | lastx = next; 72 | } 73 | 74 | after.reverse(); 75 | last = layer[0]; 76 | lastx = last.x; 77 | last.x = (lastx + after[0]) / 2; 78 | for (const [i, sugi] of entries(slice(layer, 1))) { 79 | const next = Math.max(sugi.x, lastx + sep(last, sugi)); 80 | sugi.x = (next + after[i + 1]) / 2; 81 | last = sugi; 82 | lastx = next; 83 | } 84 | } 85 | 86 | /** detect if the nodes are unchanged from the snapshot */ 87 | function unchanged(layers: SugiNode[][], snapshot: number[][]): boolean { 88 | for (const [i, layer] of layers.entries()) { 89 | const snap = snapshot[i]; 90 | for (const [j, { x }] of layer.entries()) { 91 | if (snap[j] !== x) { 92 | return false; 93 | } 94 | } 95 | } 96 | return true; 97 | } 98 | 99 | /** 100 | * create a new {@link CoordGreedy} 101 | * 102 | * This coordinate assignment operator tries to position nodes close to their 103 | * parents, but is more lenient in the constraint, so tends to be faster than 104 | * optimization based coordinate assignments, but runs much faster. 105 | * 106 | * @example 107 | * 108 | * ```ts 109 | * const layout = sugiyama().coord(coordGreedy()); 110 | * ``` 111 | */ 112 | export function coordGreedy(...args: never[]): CoordGreedy { 113 | if (args.length) { 114 | throw err`got arguments to coordGreedy(${args}); you probably forgot to construct coordGreedy before passing to coord: \`sugiyama().coord(coordGreedy())\`, note the trailing "()"`; 115 | } 116 | 117 | function coordGreedy( 118 | layers: SugiNode[][], 119 | sep: SugiSeparation, 120 | ): number { 121 | // get statistics on layers 122 | let xmean = 0; 123 | let xcount = 0; 124 | const nodes = new Set>(); 125 | for (const layer of layers) { 126 | for (const node of layer) { 127 | if (!nodes.has(node)) { 128 | nodes.add(node); 129 | if (node.ux !== undefined) { 130 | xmean += (node.ux - xmean) / ++xcount; 131 | } 132 | } 133 | } 134 | } 135 | 136 | // initialize xes on all layers 137 | for (const layer of layers) { 138 | let mean = 0; 139 | let count = 0; 140 | for (const node of layer) { 141 | if (node.ux !== undefined) { 142 | mean += (node.ux - mean) / ++count; 143 | } 144 | } 145 | const def = count ? mean : xmean; 146 | for (const node of layer) { 147 | if (node.ux === undefined) { 148 | node.ux = def; 149 | } 150 | } 151 | space(layer, sep); 152 | } 153 | 154 | // do an up and down pass using the assignment operator 155 | // down pass 156 | for (const layer of slice(layers, 1)) { 157 | assign(layer, (node) => node.parents()); 158 | space(layer, sep); 159 | } 160 | // up pass 161 | for (const layer of slice(layers, layers.length - 2, -1, -1)) { 162 | assign(layer, (node) => node.children()); 163 | space(layer, sep); 164 | } 165 | 166 | // another set of passes to guarantee nodes spaced apart enough and long edges are straight 167 | for (let i = 0; i < layers.length; ++i) { 168 | const snapshot = layers.map((layer) => layer.map(({ x }) => x)); 169 | for (const layer of slice(layers, 1)) { 170 | straighten(layer, (node) => node.parents()); 171 | space(layer, sep); 172 | } 173 | for (const layer of slice(layers, layers.length - 2, -1, -1)) { 174 | straighten(layer, (node) => node.children()); 175 | space(layer, sep); 176 | } 177 | if (unchanged(layers, snapshot)) { 178 | break; 179 | } 180 | } 181 | 182 | // figure out width and offset 183 | let start = Infinity; 184 | let end = -Infinity; 185 | for (const layer of layers) { 186 | const first = layer[0]; 187 | start = Math.min(start, first.x - sep(undefined, first)); 188 | const last = layer[layer.length - 1]; 189 | end = Math.max(end, last.x + sep(last, undefined)); 190 | } 191 | 192 | // apply offset 193 | for (const node of nodes) { 194 | node.x -= start; 195 | } 196 | 197 | const width = end - start; 198 | if (width <= 0) { 199 | throw err`must assign nonzero width to at least one node; double check the callback passed to \`sugiyama().nodeSize(...)\``; 200 | } 201 | return width; 202 | } 203 | 204 | coordGreedy.d3dagBuiltin = true as const; 205 | 206 | return coordGreedy; 207 | } 208 | -------------------------------------------------------------------------------- /src/sugiyama/coord/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { GraphNode } from "../../graph"; 3 | import { bigrams } from "../../iters"; 4 | import { sugiNodeLength } from "../sugify"; 5 | import { createLayers, nodeSep } from "../test-utils"; 6 | import { sizedSeparation } from "../utils"; 7 | import { coordCenter } from "./center"; 8 | import { coordGreedy } from "./greedy"; 9 | import { coordQuad } from "./quad"; 10 | import { coordSimplex } from "./simplex"; 11 | 12 | const square = () => createLayers([[[0], [1]], [[0], [0]], [[]]]); 13 | const ccoz = () => createLayers([[[0, 1], [2], [3]], [[0], [0], [], []], [[]]]); 14 | const dtopo = () => createLayers([[[0, 1]], [[], 0], [[]], [[0]], [[]]]); 15 | const doub = () => createLayers([[[0], []], [[]]]); 16 | const vee = () => createLayers([[[0], [0]], [[]]]); 17 | const ex = () => 18 | createLayers([ 19 | [[1], [0]], 20 | [[], []], 21 | ]); 22 | const compact = () => 23 | createLayers([ 24 | [[1], [0], 2n, [3]], 25 | [[], [], [], []], 26 | ]); 27 | 28 | const indexLength = (node: GraphNode<{ index: number }>): number => 29 | node.data.index + 1; 30 | const idLayerSep = sizedSeparation(sugiNodeLength(indexLength, 1), 0); 31 | 32 | for (const [name, method] of [ 33 | ["greedy", coordGreedy()], 34 | ["quad", coordQuad()], 35 | ["simplex", coordSimplex()], 36 | ["center", coordCenter()], 37 | ] as const) { 38 | for (const dat of [square, ccoz, dtopo, doub, vee, ex, compact]) { 39 | test(`invariants apply to ${dat.name} assigned by ${name}`, () => { 40 | const layered = dat(); 41 | 42 | // test gaps as width 43 | const width = method(layered, nodeSep); 44 | for (const layer of layered) { 45 | for (const node of layer) { 46 | expect(node.x).toBeGreaterThanOrEqual(0); 47 | expect(node.x).toBeLessThanOrEqual(width); 48 | } 49 | for (const [first, second] of bigrams(layer)) { 50 | expect(second.x - first.x).toBeGreaterThanOrEqual( 51 | nodeSep(first, second) - 1e-3, 52 | ); 53 | } 54 | } 55 | 56 | // can reapply 57 | method(layered, nodeSep); 58 | }); 59 | } 60 | 61 | test(`single layer respects node width by ${name}`, () => { 62 | const [layer] = createLayers([[[], [], []]]); 63 | const width = method([layer], idLayerSep); 64 | expect(width).toBeCloseTo(6); 65 | const [first, second, third] = layer; 66 | expect(first.x).toBeCloseTo(0.5); 67 | expect(second.x).toBeCloseTo(2); 68 | expect(third.x).toBeCloseTo(4.5); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/sugiyama/coord/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * {@link Coord}s assign `x` coordinates to every node, while 3 | * respecting the {@link SugiSeparation}. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { SugiNode, SugiSeparation } from "../sugify"; 8 | 9 | /** 10 | * an operator that assigns coordinates to layered {@link SugiNode}s 11 | * 12 | * This function must assign each node an `x` coordinate, and return the width 13 | * of the layout. The `x` coordinates should satisfy the 14 | * {@link SugiSeparation}, and all be between zero and the returned width. 15 | * 16 | * @example 17 | * 18 | * In order to illustrate what it might look like, below we demonstrate a 19 | * coordinate operator that assigns an x attached to the nodes themselves. If 20 | * the node is a dummy node (e.g. a point on an edge between two nodes), then 21 | * instead we average the coordinate. Note that this isn't compliant since it 22 | * might not respect `sep`, and is therefore only an illustration. 23 | * 24 | * ```ts 25 | * customCoord(layers: SugiNode[][], sep: SugiSeparation): number { 26 | * // determine span of xs 27 | * let min = Infinity; 28 | * let max = -Infinity; 29 | * for (const layer of layers) { 30 | * for (const node of layer) { 31 | * const { data } = node; 32 | * const x = node.x = data.role === "node" ? data.node.data.x : (data.link.source.data.x + data.link.target.data.x) / 2; 33 | * min = Math.min(min, x - sep(undefined, node)); 34 | * max = Math.max(max, x + sep(node, undefined)); 35 | * } 36 | * } 37 | * // assign xs 38 | * for (const node of dag) { 39 | * node.x = -= min; 40 | * } 41 | * return max - min; 42 | * } 43 | * ``` 44 | */ 45 | export interface Coord { 46 | /** 47 | * assign coordinates to a layered graph 48 | * 49 | * @param layers - a layered graph of sugiyama nodes 50 | * @param sep - how much horizontal separation should exist between nodes 51 | * @returns width - the total width of the layout 52 | */ 53 | ( 54 | layers: SugiNode[][], 55 | sep: SugiSeparation, 56 | ): number; 57 | 58 | /** 59 | * This sentinel field is so that typescript can infer the types of NodeDatum 60 | * and LinkDatum, because the extra generics make it otherwise hard to infer. 61 | * It's a function to keep the same variance. 62 | * 63 | * @internal 64 | */ 65 | __sentinel__?: (_: NodeDatum, __: LinkDatum) => void; 66 | } 67 | -------------------------------------------------------------------------------- /src/sugiyama/coord/quad.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Coord } from "."; 3 | import { GraphLink } from "../../graph"; 4 | import { createLayers, nodeSep } from "../test-utils"; 5 | import { coordQuad } from "./quad"; 6 | 7 | test("coordQuad() modifiers work", () => { 8 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 9 | 10 | const comp = 0.5; 11 | function vertWeak({ source, target }: GraphLink<{ layer: number }>): number { 12 | return source.data.layer + target.data.layer + 1; 13 | } 14 | function vertStrong({ 15 | source, 16 | target, 17 | }: GraphLink<{ index: number | string }>): number { 18 | return +source.data.index + +target.data.index + 1; 19 | } 20 | function linkCurve({ data }: { data: undefined }): number { 21 | return data ?? 1; 22 | } 23 | function nodeCurve({ data }: { data: { index: number } }): number { 24 | return data.index + 1; 25 | } 26 | 27 | const init = coordQuad() satisfies Coord; 28 | const vert = init.vertWeak(vertWeak).vertStrong(vertStrong); 29 | vert satisfies Coord<{ layer: number; index: number | string }, unknown>; 30 | // @ts-expect-error invalid data 31 | vert satisfies Coord; 32 | const advanced = vert 33 | .linkCurve(linkCurve) 34 | .nodeCurve(nodeCurve) 35 | .compress(comp); 36 | advanced satisfies Coord<{ index: number; layer: number }, undefined>; 37 | // @ts-expect-error invalid data 38 | advanced satisfies Coord<{ layer: number; index: number | string }, unknown>; 39 | expect(advanced.vertWeak() satisfies typeof vertWeak).toBe(vertWeak); 40 | expect(advanced.vertStrong() satisfies typeof vertStrong).toBe(vertStrong); 41 | expect(advanced.linkCurve() satisfies typeof linkCurve).toBe(linkCurve); 42 | expect(advanced.nodeCurve() satisfies typeof nodeCurve).toBe(nodeCurve); 43 | expect(advanced.compress()).toEqual(comp); 44 | advanced(layers, nodeSep); 45 | }); 46 | 47 | test("coordQuad() works for square like layout", () => { 48 | const layers = createLayers([[[0, 1]], [[0], [0]], [[]]]); 49 | const [[head], [left, right], [tail]] = layers; 50 | coordQuad()(layers, nodeSep); 51 | 52 | expect(head.x).toBeCloseTo(1.0); 53 | expect(left.x).toBeCloseTo(0.5); 54 | expect(right.x).toBeCloseTo(1.5); 55 | expect(tail.x).toBeCloseTo(1.0); 56 | }); 57 | 58 | test("coordQuad() works for triangle", () => { 59 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 60 | const [[one], [two, dummy], [three]] = layers; 61 | coordQuad()(layers, nodeSep); 62 | 63 | expect(one.x).toBeCloseTo(1.1); 64 | expect(two.x).toBeCloseTo(0.5); 65 | expect(three.x).toBeCloseTo(1.1); 66 | expect(dummy.x).toBeCloseTo(1.5); 67 | }); 68 | 69 | test("coordQuad() works with flat disconnected component", () => { 70 | const layers = createLayers([[[], []], [[0]], [[]]]); 71 | const [[left, right], [high], [low]] = layers; 72 | coordQuad()(layers, nodeSep); 73 | 74 | expect(left.x).toBeCloseTo(0.5); 75 | expect(right.x).toBeCloseTo(1.5); 76 | expect(high.x).toBeCloseTo(1.0); 77 | expect(low.x).toBeCloseTo(1.0); 78 | }); 79 | 80 | test("coordQuad() works with constrained disconnected components", () => { 81 | // NOTE before the top and bottom nodes would be pulled together by the 82 | // connected component minimization, but since they're not constrained due to 83 | // overlap, we can ignore it in this test case 84 | const layers = createLayers([ 85 | [[0], [1, 3]], 86 | [[0], [], [0], [1]], 87 | [[], []], 88 | ]); 89 | const [[atop, btop], [aleft, bleft, aright, bright], [abottom, bbottom]] = 90 | layers; 91 | const layout = coordQuad(); 92 | const width = layout(layers, nodeSep); 93 | 94 | expect(width).toBeCloseTo(4); 95 | expect(atop.x).toBeCloseTo(0.5); 96 | expect(aleft.x).toBeCloseTo(0.5); 97 | expect(aright.x).toBeCloseTo(2.5); 98 | expect(abottom.x).toBeCloseTo(1.5); 99 | expect(btop.x).toBeCloseTo(2.5); 100 | expect(bleft.x).toBeCloseTo(1.5); 101 | expect(bright.x).toBeCloseTo(3.5); 102 | expect(bbottom.x).toBeCloseTo(3.5); 103 | }); 104 | 105 | test("coordQuad() works with compact dag", () => { 106 | // r 107 | // / \ 108 | // / # 109 | // l c r 110 | // \ # 111 | // \ / 112 | // t 113 | const layers = createLayers([ 114 | [0n], 115 | [[0, 1]], 116 | [0, 2n], 117 | [0n, 1n, 2n], 118 | [[0], [], 1n], 119 | [0, [0]], 120 | [0n], 121 | [[]], 122 | ]); 123 | const layout = coordQuad(); 124 | const width = layout(layers, nodeSep); 125 | 126 | expect(width).toBeCloseTo(3); 127 | for (const layer of layers) { 128 | for (const node of layer) { 129 | expect(node.x).toBeGreaterThanOrEqual(0); 130 | expect(node.x).toBeLessThanOrEqual(width); 131 | } 132 | } 133 | }); 134 | 135 | test("coordQuad() fails with invalid weights", () => { 136 | const layout = coordQuad(); 137 | expect(() => layout.compress(0)).toThrow( 138 | "compress weight must be positive, but was: 0", 139 | ); 140 | }); 141 | 142 | test("coordQuad() fails with negative constant vert weak", () => { 143 | const layout = coordQuad(); 144 | expect(() => layout.vertWeak(-1)).toThrow("vertWeak must be non-negative"); 145 | }); 146 | 147 | test("coordQuad() fails with negative constant vert string", () => { 148 | const layout = coordQuad(); 149 | expect(() => layout.vertStrong(-1)).toThrow( 150 | "vertStrong must be non-negative", 151 | ); 152 | }); 153 | 154 | test("coordQuad() fails with negative constant link curve", () => { 155 | const layout = coordQuad(); 156 | expect(() => layout.linkCurve(-1)).toThrow("linkCurve must be non-negative"); 157 | }); 158 | 159 | test("coordQuad() fails with negative constant node curve", () => { 160 | const layout = coordQuad(); 161 | expect(() => layout.nodeCurve(-1)).toThrow("nodeCurve must be non-negative"); 162 | }); 163 | 164 | test("coordQuad() fails with negative vert weak", () => { 165 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 166 | const layout = coordQuad().vertWeak(() => -1); 167 | expect(() => layout(layers, nodeSep)).toThrow( 168 | `link weights must be non-negative`, 169 | ); 170 | }); 171 | 172 | test("coordQuad() fails with negative link weight", () => { 173 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 174 | const layout = coordQuad().linkCurve(() => -1); 175 | expect(() => layout(layers, nodeSep)).toThrow( 176 | `link weights must be non-negative`, 177 | ); 178 | }); 179 | 180 | test("coordQuad() fails with negative node weight", () => { 181 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 182 | const layout = coordQuad().nodeCurve(() => -1); 183 | expect(() => layout(layers, nodeSep)).toThrow( 184 | `node weights must be non-negative`, 185 | ); 186 | }); 187 | 188 | test("coordQuad() fails passing an arg to constructor", () => { 189 | // @ts-expect-error no args 190 | expect(() => coordQuad(null)).toThrow("got arguments to coordQuad"); 191 | }); 192 | 193 | test("coordQuad() throws for zero width", () => { 194 | const layers = createLayers([[[]]]); 195 | expect(() => coordQuad()(layers, () => 0)).toThrow( 196 | "must assign nonzero width to at least one node", 197 | ); 198 | }); 199 | -------------------------------------------------------------------------------- /src/sugiyama/coord/simplex.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Coord } from "."; 3 | import { GraphLink } from "../../graph"; 4 | import { flatMap } from "../../iters"; 5 | import { sugiNodeLength } from "../sugify"; 6 | import { createLayers, nodeSep } from "../test-utils"; 7 | import { sizedSeparation } from "../utils"; 8 | import { coordSimplex } from "./simplex"; 9 | 10 | test("coordSimplex() modifiers work", () => { 11 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 12 | function weight({ 13 | source, 14 | target, 15 | }: GraphLink<{ index: number }>): [number, number, number] { 16 | return [source.data.index == target.data.index ? 1 : 2, 3, 4]; 17 | } 18 | 19 | const init = coordSimplex() satisfies Coord; 20 | 21 | const layout = init.weight(weight) satisfies Coord< 22 | { index: number }, 23 | unknown 24 | >; 25 | // @ts-expect-error new weight 26 | layout satisfies Coord; 27 | 28 | expect(layout.weight() satisfies typeof weight).toBe(weight); 29 | layout(layers, nodeSep); 30 | 31 | for (const node of flatMap(layers, (l) => l)) { 32 | expect(node.x).toBeDefined(); 33 | } 34 | }); 35 | 36 | test("coordSimplex() works for square like layout", () => { 37 | const layers = createLayers([[[0, 1]], [[0], [0]], [[]]]); 38 | const [[head], [left, right], [tail]] = layers; 39 | const layout = coordSimplex(); 40 | const width = layout(layers, nodeSep); 41 | 42 | expect(width).toBeCloseTo(2); 43 | 44 | // NOTE head and tail could be at either 0.5 or 1.5 45 | expect(head.x).toBeCloseTo(1.5); 46 | expect(left.x).toBeCloseTo(0.5); 47 | expect(right.x).toBeCloseTo(1.5); 48 | expect(tail.x).toBeCloseTo(1.5); 49 | }); 50 | 51 | test("coordSimplex() works for triangle", () => { 52 | const layers = createLayers([[[0, 1]], [0, [0]], [[]]]); 53 | const [[one], [dummy, two], [three]] = layers; 54 | const layout = coordSimplex(); 55 | const sep = sizedSeparation( 56 | sugiNodeLength(() => 1), 57 | 0, 58 | ); 59 | const width = layout(layers, sep); 60 | 61 | // NOTE with dummy node first, we guarantee that that's the straight line 62 | expect(width).toBeCloseTo(1.5); 63 | expect(one.x).toBeCloseTo(0.5); 64 | expect(dummy.x).toBeCloseTo(0.5); 65 | expect(three.x).toBeCloseTo(0.5); 66 | expect(two.x).toBeCloseTo(1.0); 67 | }); 68 | 69 | test("coordSimplex() works for dee", () => { 70 | const layers = createLayers([[[0, 1]], [0, [1]], [0, [0]], [[]]]); 71 | const [[one], [d1, two], [d2, three], [four]] = layers; 72 | const layout = coordSimplex(); 73 | layout(layers, nodeSep); 74 | 75 | // NOTE with dummy node first, we guarantee that that's the straight line 76 | expect(one.x).toBeCloseTo(0.5); 77 | expect(d1.x).toBeCloseTo(0.5); 78 | expect(d2.x).toBeCloseTo(0.5); 79 | expect(four.x).toBeCloseTo(0.5); 80 | expect(two.x).toBeCloseTo(1.5); 81 | expect(three.x).toBeCloseTo(1.5); 82 | }); 83 | 84 | test("coordSimplex() works for dee with custom weights", () => { 85 | const layers = createLayers([[[0, 1]], [0, [1]], [0, [0]], [[]]]); 86 | const [[one], [d1, two], [d2, three], [four]] = layers; 87 | const layout = coordSimplex().weight(() => [2, 3, 4]); 88 | layout(layers, nodeSep); 89 | 90 | // NOTE with dummy node first, we guarantee that that's the straight line 91 | expect(one.x).toBeCloseTo(0.5); 92 | expect(d1.x).toBeCloseTo(0.5); 93 | expect(d2.x).toBeCloseTo(0.5); 94 | expect(four.x).toBeCloseTo(0.5); 95 | expect(two.x).toBeCloseTo(1.5); 96 | expect(three.x).toBeCloseTo(1.5); 97 | }); 98 | 99 | test("coordSimplex() works with flat disconnected component", () => { 100 | const layers = createLayers([[[], []], [[0]], [[]]]); 101 | const [[left, right], [high], [low]] = layers; 102 | const layout = coordSimplex(); 103 | layout(layers, nodeSep); 104 | 105 | expect(left.x).toBeCloseTo(0.5); 106 | expect(right.x).toBeCloseTo(1.5); 107 | expect(high.x).toBeCloseTo(0.5); 108 | expect(low.x).toBeCloseTo(0.5); 109 | }); 110 | 111 | test("coordSimplex() works with complex disconnected component", () => { 112 | const layers = createLayers([[[0], [], [0]], [[], [0]], [[]]]); 113 | const [[left, middle, right], [vee, above], [below]] = layers; 114 | const layout = coordSimplex(); 115 | layout(layers, nodeSep); 116 | 117 | expect(left.x).toBeCloseTo(0.5); 118 | expect(middle.x).toBeCloseTo(1.5); 119 | expect(right.x).toBeCloseTo(2.5); 120 | expect(vee.x).toBeCloseTo(2.5); 121 | expect(above.x).toBeCloseTo(3.5); 122 | expect(below.x).toBeCloseTo(3.5); 123 | }); 124 | 125 | test("coordSimplex() works with compact dag", () => { 126 | // r 127 | // / \ 128 | // / # 129 | // l c r 130 | // \ # 131 | // \ / 132 | // t 133 | const layers = createLayers([ 134 | [0n], 135 | [[0, 1]], 136 | [0, 2n], 137 | [0n, 1n, 2n], 138 | [[0], [], 1n], 139 | [0, [0]], 140 | [0n], 141 | [[]], 142 | ]); 143 | const [[root], , [topDummy], [left, center, right], , [bottomDummy], [tail]] = 144 | layers; 145 | const layout = coordSimplex(); 146 | const width = layout(layers, nodeSep); 147 | 148 | expect(width).toBeCloseTo(3); 149 | expect(root.x).toBeCloseTo(0.5); 150 | expect(topDummy.x).toBeCloseTo(0.5); 151 | expect(left.x).toBeCloseTo(0.5); 152 | expect(center.x).toBeCloseTo(1.5); 153 | expect(right.x).toBeCloseTo(2.5); 154 | expect(bottomDummy.x).toBeCloseTo(0.5); 155 | expect(tail.x).toBeCloseTo(0.5); 156 | }); 157 | 158 | test("coordSimplex() fails with non-positive constant weight", () => { 159 | const layout = coordSimplex(); 160 | expect(() => layout.weight([0, 1, 2])).toThrow( 161 | "simplex weights must be positive, but got", 162 | ); 163 | }); 164 | 165 | test("coordSimplex() fails with non-positive weight", () => { 166 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 167 | const layout = coordSimplex().weight(() => [0, 1, 2]); 168 | expect(() => layout(layers, nodeSep)).toThrow( 169 | "simplex weights must be positive, but got", 170 | ); 171 | }); 172 | 173 | test("coordSimplex() fails passing an arg to constructor", () => { 174 | // @ts-expect-error no args 175 | expect(() => coordSimplex(null)).toThrow("got arguments to coordSimplex"); 176 | }); 177 | 178 | test("coordSimplex() throws for zero width", () => { 179 | const layers = createLayers([[[]]]); 180 | const layout = coordSimplex(); 181 | expect(() => layout(layers, () => 0)).toThrow( 182 | "must assign nonzero width to at least one node", 183 | ); 184 | }); 185 | -------------------------------------------------------------------------------- /src/sugiyama/coord/topological.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { createLayers, nodeSep } from "../test-utils"; 3 | import { coordTopological as topological } from "./topological"; 4 | 5 | test("topological() works for triangle", () => { 6 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 7 | const [[one], [two, dummy], [three]] = layers; 8 | const coord = topological(); 9 | expect(coord.straight()).toBe(true); 10 | coord(layers, nodeSep); 11 | 12 | expect(one.x).toBeCloseTo(0.5, 7); 13 | expect(two.x).toBeCloseTo(0.5, 7); 14 | expect(three.x).toBeCloseTo(0.5, 7); 15 | expect(dummy.x).toBeCloseTo(1.5, 7); 16 | }); 17 | 18 | test("topological() works for multiple edges", () => { 19 | const layers = createLayers([ 20 | [[0, 1, 2, 3]], 21 | [0, [1, 2], 2, 3], 22 | [0, 0, [0], 0], 23 | [[]], 24 | ]); 25 | const [[one], [d1, two, d2, d3], [d4, d5, three, d6], [four]] = layers; 26 | const coord = topological(); 27 | coord(layers, nodeSep); 28 | 29 | expect(one.x).toBeCloseTo(2.5, 7); 30 | expect(two.x).toBeCloseTo(2.5, 7); 31 | expect(three.x).toBeCloseTo(2.5, 7); 32 | expect(four.x).toBeCloseTo(2.5, 7); 33 | expect(d1.x).toBeCloseTo(0.5); 34 | expect(d2.x).toBeCloseTo(3.5); 35 | expect(d3.x).toBeCloseTo(4.5); 36 | expect(d4.x).toBeCloseTo(0.5); 37 | expect(d5.x).toBeCloseTo(1.5); 38 | expect(d6.x).toBeCloseTo(4.5); 39 | }); 40 | 41 | test("topological() works for disconnected", () => { 42 | const layered = createLayers([[[0, 1]], [[], 0], [[]], [[0]], [[]]]); 43 | const coord = topological(); 44 | const width = coord(layered, nodeSep); 45 | for (const layer of layered) { 46 | for (const node of layer) { 47 | expect(node.x).toBeGreaterThanOrEqual(0); 48 | expect(node.x).toBeLessThanOrEqual(width); 49 | } 50 | } 51 | }); 52 | 53 | test("topological().straight(false) works for triangle", () => { 54 | const layers = createLayers([[[0, 1]], [[0], 0], [[]]]); 55 | const [[one], [two, dummy], [three]] = layers; 56 | const coord = topological().straight(false); 57 | expect(coord.straight()).toBe(false); 58 | coord(layers, nodeSep); 59 | 60 | expect(one.x).toBeCloseTo(0.5, 7); 61 | expect(two.x).toBeCloseTo(0.5, 7); 62 | expect(three.x).toBeCloseTo(0.5, 7); 63 | expect(dummy.x).toBeCloseTo(1.5, 7); 64 | }); 65 | 66 | test("topological().straight(false) works for disconnected", () => { 67 | const layered = createLayers([[[0, 1]], [[], 0], [[]], [[0]], [[]]]); 68 | const coord = topological().straight(false); 69 | const width = coord(layered, nodeSep); 70 | for (const layer of layered) { 71 | for (const node of layer) { 72 | expect(node.x).toBeGreaterThanOrEqual(0); 73 | expect(node.x).toBeLessThanOrEqual(width); 74 | } 75 | } 76 | }); 77 | 78 | test("topological() works for compact", () => { 79 | const layered = createLayers([[0n], [[0, 1]], [0n, 1], [[], 0], [0n], [[]]]); 80 | const coord = topological(); 81 | const width = coord(layered, nodeSep); 82 | for (const layer of layered) { 83 | for (const node of layer) { 84 | expect(node.x).toBeGreaterThanOrEqual(0); 85 | expect(node.x).toBeLessThanOrEqual(width); 86 | } 87 | } 88 | }); 89 | 90 | test("topological().straight(false) works for compact", () => { 91 | const layered = createLayers([[0n], [[0, 1]], [0n, 1], [[], 0], [0n], [[]]]); 92 | const coord = topological().straight(false); 93 | const width = coord(layered, nodeSep); 94 | for (const layer of layered) { 95 | for (const node of layer) { 96 | expect(node.x).toBeGreaterThanOrEqual(0); 97 | expect(node.x).toBeLessThanOrEqual(width); 98 | } 99 | } 100 | }); 101 | 102 | test("topological() throws for non-topological", () => { 103 | const layers = createLayers([[[0], [0]], [[]]]); 104 | expect(() => topological()(layers, nodeSep)).toThrow( 105 | "only works with a topological layering", 106 | ); 107 | }); 108 | 109 | test("topological() fails passing an arg to constructor", () => { 110 | // @ts-expect-error no args 111 | expect(() => topological(null)).toThrow("got arguments to coordTopological"); 112 | }); 113 | 114 | test("topological() throws for zero width", () => { 115 | const layers = createLayers([[[]]]); 116 | const coord = topological(); 117 | expect(() => coord(layers, () => 0)).toThrow( 118 | "must assign nonzero width to at least one node", 119 | ); 120 | }); 121 | 122 | test("topological().straight(false) throws for zero width", () => { 123 | const layers = createLayers([[[]]]); 124 | const coord = topological().straight(false); 125 | expect(() => coord(layers, () => 0)).toThrow( 126 | "must assign nonzero width to at least one node", 127 | ); 128 | }); 129 | -------------------------------------------------------------------------------- /src/sugiyama/coord/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities for quadratic optimization 3 | * 4 | * @internal 5 | * @packageDocumentation 6 | */ 7 | import { solveQP } from "quadprog"; 8 | import { GraphNode } from "../../graph"; 9 | import { bigrams, flatMap, map } from "../../iters"; 10 | import { ierr } from "../../utils"; 11 | import { SugiNode, SugiSeparation } from "../sugify"; 12 | import { aggMean } from "../twolayer/agg"; 13 | 14 | /** wrapper for solveQP */ 15 | function qp( 16 | Q: number[][], 17 | c: number[], 18 | A: number[][], 19 | b: number[], 20 | meq: number, 21 | ): number[] { 22 | if (!c.length) { 23 | return []; 24 | } 25 | 26 | const Dmat = [[0]]; 27 | const dvec = [0]; 28 | const Amat = [[0]]; 29 | const bvec = [0]; 30 | 31 | for (const qRow of Q) { 32 | const newRow = [0]; 33 | newRow.push(...qRow); 34 | Dmat.push(newRow); 35 | } 36 | dvec.push(...c); 37 | Amat.push(...c.map(() => [0])); 38 | for (const aRow of A) { 39 | for (const [j, val] of aRow.entries()) { 40 | Amat[j + 1].push(-val); 41 | } 42 | } 43 | bvec.push(...b.map((v) => -v)); 44 | 45 | const { solution, message } = solveQP(Dmat, dvec, Amat, bvec, meq); 46 | /* istanbul ignore if */ 47 | if (message.length) { 48 | throw ierr`quadratic program failed: ${message}`; 49 | } 50 | solution.shift(); 51 | return solution; 52 | } 53 | 54 | /** solve for node positions */ 55 | export function solve( 56 | Q: number[][], 57 | c: number[], 58 | A: number[][], 59 | b: number[], 60 | meq: number = 0, 61 | ): number[] { 62 | // Arbitrarily set the last coordinate to 0 (by removing it from the 63 | // equation), which makes the formula valid This is simpler than special 64 | // casing the last element 65 | c.pop(); 66 | Q.pop(); 67 | Q.forEach((row) => row.pop()); 68 | A.forEach((row) => row.pop()); 69 | 70 | // Solve 71 | const solution = qp(Q, c, A, b, meq); 72 | 73 | // Undo last coordinate removal 74 | solution.push(0); 75 | return solution; 76 | } 77 | 78 | /** compute indices used to index arrays */ 79 | export function indices( 80 | layers: SugiNode[][], 81 | ): Map, number> { 82 | const mapping = new Map, number>(); 83 | let i = 0; 84 | for (const layer of layers) { 85 | for (const node of layer) { 86 | if (!mapping.has(node)) { 87 | mapping.set(node, i++); 88 | } 89 | } 90 | } 91 | return mapping; 92 | } 93 | 94 | /** Compute constraint arrays for layer separation */ 95 | export function init( 96 | layers: SugiNode[][], 97 | inds: Map, 98 | sep: SugiSeparation, 99 | compress: number = 0, 100 | ): [number[][], number[], number[][], number[]] { 101 | // NOTE max because we might assign a node the same index 102 | const n = 1 + Math.max(...inds.values()); 103 | 104 | const Q = Array(n) 105 | .fill(null) 106 | .map((_, i) => 107 | Array(n) 108 | .fill(null) 109 | .map((_, j) => (i === j ? compress : 0)), 110 | ); 111 | const c = Array(n).fill(0); 112 | const A: number[][] = []; 113 | const b: number[] = []; 114 | 115 | for (const layer of layers) { 116 | for (const [first, second] of bigrams(layer)) { 117 | const find = inds.get(first)!; 118 | const sind = inds.get(second)!; 119 | const cons = Array(n).fill(0); 120 | cons[find] = 1; 121 | cons[sind] = -1; 122 | A.push(cons); 123 | b.push(-sep(first, second)); 124 | } 125 | } 126 | 127 | return [Q, c, A, b]; 128 | } 129 | 130 | /** update Q that minimizes edge distance squared */ 131 | export function minDist( 132 | Q: number[][], 133 | pind: number, 134 | cind: number, 135 | coef: number, 136 | ): void { 137 | Q[cind][cind] += coef; 138 | Q[cind][pind] -= coef; 139 | Q[pind][cind] -= coef; 140 | Q[pind][pind] += coef; 141 | } 142 | 143 | /** 144 | * update Q that minimizes curve of edges through a node where curve is 145 | * calculates as the squared distance of the middle node from the midpoint of 146 | * the first and last, multiplied by four for some reason 147 | */ 148 | export function minBend( 149 | Q: number[][], 150 | pind: number, 151 | nind: number, 152 | cind: number, 153 | pcoef: number, 154 | ccoef: number, 155 | ): void { 156 | const ncoef = pcoef + ccoef; 157 | Q[cind][cind] += ccoef * ccoef; 158 | Q[cind][nind] -= ccoef * ncoef; 159 | Q[cind][pind] += ccoef * pcoef; 160 | Q[nind][cind] -= ncoef * ccoef; 161 | Q[nind][nind] += ncoef * ncoef; 162 | Q[nind][pind] -= ncoef * pcoef; 163 | Q[pind][cind] += pcoef * ccoef; 164 | Q[pind][nind] -= pcoef * ncoef; 165 | Q[pind][pind] += pcoef * pcoef; 166 | } 167 | 168 | /** 169 | * Assign nodes x based off of solution, and return the width of the final 170 | * layout. 171 | */ 172 | export function layout( 173 | layers: SugiNode[][], 174 | sep: SugiSeparation, 175 | inds: Map, 176 | solution: number[], 177 | ): number { 178 | // assign solution 179 | for (const [node, key] of inds) { 180 | node.x = solution[key]; 181 | } 182 | 183 | // find span of solution 184 | let start = Infinity; 185 | let finish = -Infinity; 186 | for (const layer of layers) { 187 | const first = layer[0]; 188 | const last = layer[layer.length - 1]; 189 | 190 | start = Math.min(start, first.x - sep(undefined, first)); 191 | finish = Math.max(finish, last.x + sep(last, undefined)); 192 | } 193 | 194 | // assign indices based off of span 195 | for (const node of inds.keys()) { 196 | node.x -= start; 197 | } 198 | 199 | // return width 200 | return finish - start; 201 | } 202 | 203 | export function avgHeight(nodes: Iterable): number { 204 | // NOTE graph is guaranteed not to be multi 205 | return aggMean( 206 | flatMap(nodes, (node) => map(node.children(), (child) => child.y - node.y)), 207 | )!; 208 | } 209 | -------------------------------------------------------------------------------- /src/sugiyama/decross/dfs.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { createLayers, getIndex } from "../test-utils"; 3 | import { decrossDfs } from "./dfs"; 4 | 5 | test("decrossDfs() works on trivial case", () => { 6 | // o o o o 7 | // X -> | | 8 | // o o o o 9 | const layers = createLayers([ 10 | [[1], [0]], 11 | [[], []], 12 | ]); 13 | const decross = decrossDfs(); 14 | decross(layers); 15 | const inds = layers.map((layer) => layer.map(getIndex)); 16 | expect(inds).toEqual([ 17 | [1, 0], 18 | [0, 1], 19 | ]); 20 | }); 21 | 22 | test("decrossDfs() works on compact trivial case", () => { 23 | // o 24 | // | 25 | // | o 26 | // | 27 | // o 28 | const layers = createLayers([[0n], [[0]], [0, 1n], [0, []], [0n], [[]]]); 29 | const decross = decrossDfs(); 30 | decross(layers); 31 | const inds = layers.map((layer) => layer.map(getIndex)); 32 | expect(inds).toEqual([[0], [0], [1, null], [1, null], [0], [0]]); 33 | }); 34 | 35 | test("decrossDfs() works on trivial case bottom up", () => { 36 | // o o o o 37 | // X -> | | 38 | // o o o o 39 | const layers = createLayers([ 40 | [[1], [0]], 41 | [[], []], 42 | ]); 43 | const decross = decrossDfs().topDown(false); 44 | expect(decross.topDown()).toBe(false); 45 | decross(layers); 46 | const inds = layers.map((layer) => layer.map(getIndex)); 47 | expect(inds).toEqual([ 48 | [0, 1], 49 | [1, 0], 50 | ]); 51 | }); 52 | 53 | test("decrossDfs() fails passing an arg to constructor", () => { 54 | // @ts-expect-error no args 55 | expect(() => decrossDfs(null)).toThrow("got arguments to decrossDfs"); 56 | }); 57 | -------------------------------------------------------------------------------- /src/sugiyama/decross/dfs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A {@link DecrossDfs} heuristic for quickly producing reasonable crossings. 3 | * This is intended for use as an initialization step. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { Decross } from "."; 8 | import { filter, flatMap, slice } from "../../iters"; 9 | import { dfs as depthFirstSearch, err } from "../../utils"; 10 | import { SugiNode } from "../sugify"; 11 | 12 | /** 13 | * a depth first search operator 14 | * 15 | * This is a fast heuristic that runs a depth first search, incrementally 16 | * adding nodes to their appropriate layer. It creates a reasonable ordering to 17 | * potentially be further optimized by other operators. 18 | */ 19 | export interface DecrossDfs extends Decross { 20 | /** 21 | * sets whether the dfs should be top down or bottom up 22 | * 23 | * This has a small tweak in effect and can be useful for multiple initial 24 | * configurations. 25 | * 26 | * (default: `true`) 27 | */ 28 | topDown(val: boolean): DecrossDfs; 29 | /** 30 | * get whether the current operator is topDown 31 | */ 32 | topDown(): boolean; 33 | 34 | /** @internal flag indicating that this is built in to d3dag and shouldn't error in specific instances */ 35 | readonly d3dagBuiltin: true; 36 | } 37 | 38 | /** @internal */ 39 | function buildOperator(options: { topDown: boolean }): DecrossDfs { 40 | function decrossDfs(layers: SugiNode[][]): void { 41 | // get iteration over nodes in dfs order 42 | // we heuristically prioritize nodes with a fewer number of children 43 | // NOTE with dfs, the priority is for the last element 44 | let iter: Iterable; 45 | if (options.topDown) { 46 | iter = depthFirstSearch( 47 | (n) => [...n.children()].sort((a, b) => b.nchildren() - a.nchildren()), 48 | ...flatMap(layers, (layer) => 49 | [...filter(layer, (n) => !n.nparents())].sort( 50 | (a, b) => b.nchildren() - a.nchildren(), 51 | ), 52 | ), 53 | ); 54 | } else { 55 | iter = depthFirstSearch( 56 | (n) => [...n.parents()].sort((a, b) => b.nparents() - a.nparents()), 57 | ...flatMap(slice(layers, layers.length - 1, -1, -1), (layer) => 58 | [...filter(layer, (n) => !n.nchildren())].sort( 59 | (a, b) => b.nparents() - a.nparents(), 60 | ), 61 | ), 62 | ); 63 | } 64 | 65 | // since we know we'll hit every node in iteration, we can clear the layers 66 | for (const layer of layers) { 67 | layer.splice(0); 68 | } 69 | 70 | // re-add in the order seen 71 | for (const node of iter) { 72 | const { data } = node; 73 | if (data.role === "node") { 74 | for (let layer = data.topLayer; layer <= data.bottomLayer; ++layer) { 75 | layers[layer].push(node); 76 | } 77 | } else { 78 | layers[data.layer].push(node); 79 | } 80 | } 81 | } 82 | 83 | function topDown(val: boolean): DecrossDfs; 84 | function topDown(): boolean; 85 | function topDown(val?: boolean): boolean | DecrossDfs { 86 | if (val === undefined) { 87 | return options.topDown; 88 | } else { 89 | return buildOperator({ topDown: val }); 90 | } 91 | } 92 | decrossDfs.topDown = topDown; 93 | 94 | decrossDfs.d3dagBuiltin = true as const; 95 | 96 | return decrossDfs; 97 | } 98 | 99 | /** 100 | * create a default {@link DecrossDfs} 101 | * 102 | * This is a fast heuristic decrossings operator that runs a depth first 103 | * search, incrementally adding nodes to their appropriate layer. It creates a 104 | * reasonable ordering to potentially be further optimized by other operators. 105 | * 106 | * @example 107 | * ```ts 108 | * const layout = sugiyama().decross(decrossDfs()); 109 | * ``` 110 | */ 111 | export function decrossDfs(...args: never[]): DecrossDfs { 112 | if (args.length) { 113 | throw err`got arguments to decrossDfs(${args}); you probably forgot to construct decrossDfs before passing to decross: \`sugiyama().decross(decrossDfs())\`, note the trailing "()"`; 114 | } 115 | return buildOperator({ topDown: true }); 116 | } 117 | -------------------------------------------------------------------------------- /src/sugiyama/decross/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { bigrams } from "../../iters"; 3 | import { compactCrossings, createLayers, getIndex } from "../test-utils"; 4 | import { twolayerAgg } from "../twolayer/agg"; 5 | import { twolayerOpt } from "../twolayer/opt"; 6 | import { decrossDfs } from "./dfs"; 7 | import { decrossOpt } from "./opt"; 8 | import { decrossTwoLayer } from "./two-layer"; 9 | 10 | const square = () => createLayers([[[0, 1]], [[0], [0]], [[]]]); 11 | const ccoz = () => createLayers([[[1], [0, 3], [2]], [[0], [], [], [0]], [[]]]); 12 | const dtopo = () => createLayers([[[0, 1]], [[], 0], [[]], [[0]], [[]]]); 13 | const doub = () => 14 | createLayers([ 15 | [[1], [0]], 16 | [[], []], 17 | ]); 18 | const sizedLine = () => createLayers([[0n], [[0]], [0n], [[]]]); 19 | 20 | for (const dat of [square, ccoz, dtopo, doub, sizedLine]) { 21 | for (const [name, method] of [ 22 | ["two layer agg", decrossTwoLayer().order(twolayerAgg())], 23 | ["two layer opt", decrossTwoLayer().order(twolayerOpt())], 24 | ["opt", decrossOpt()], 25 | ["dfs top down", decrossDfs().topDown(true)], 26 | ["dfs bottom up", decrossDfs().topDown(false)], 27 | ] as const) { 28 | test(`invariants apply to ${dat.name} decrossed by ${name}`, () => { 29 | const layered = dat(); 30 | const before = layered.map((layer) => layer.map(getIndex).sort()); 31 | method(layered); 32 | const after = layered.map((layer) => layer.map(getIndex).sort()); 33 | expect(after).toEqual(before); 34 | 35 | for (const [topLayer, bottomLayer] of bigrams(layered)) { 36 | expect(compactCrossings(topLayer, bottomLayer)).toBeFalsy(); 37 | } 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/sugiyama/decross/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A {@link Decross} rearranges nodes within a layer to minimize 3 | * decrossings. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { SugiNode } from "../sugify"; 8 | 9 | /** 10 | * a decrossing operator rearranges the nodes in a layer to minimize decrossings. 11 | * 12 | * A decrossing operator takes a array of layered nodes and reorders them. 13 | * There is no specific requirement on what the final order should be, but for 14 | * layouts to look good, most decrossing operators should seek to minimize the 15 | * number of link crossings, among other constraints. 16 | * 17 | * Minimizing the number of link crossings is an NP-Complete problem, so fully 18 | * minimizing decrossings {@link decrossOpt | optimally} can be prohibitively 19 | * expensive, causing javascript to crash or run forever on large dags. In 20 | * these instances it may be necessary to use an 21 | * {@link decrossTwoLayer | approximate decrossing minimization}. You may also 22 | * have special constraints around the order of nodes that can be expressed 23 | * with this operator. 24 | * 25 | * @example 26 | * 27 | * A common case might be that you want to use an optimal decrossing method if 28 | * the layout is small, but use a heuristic if the graph is too large. This can 29 | * be easily accomplished with a custom decrossing operator. 30 | * 31 | * ```ts 32 | * const opt = decrossOpt(); 33 | * const heuristic = decrossTwoLayer(); 34 | * 35 | * function decrossFallback(layers: SugiNode[][]): void { 36 | * try { 37 | * opt(layers); 38 | * } catch { 39 | * heuristic(layers); 40 | * } 41 | * } 42 | * ``` 43 | * 44 | * @example 45 | * 46 | * We illustrate a custom decrossing operator by assuming each node is ordered 47 | * by an `ord` value. For dummy nodes (points on longer edges between nodes) we 48 | * use the average value between the nodes on each end. 49 | * 50 | * ```ts 51 | * function customDecross(layers: SugiNode<{ ord: number }, unknown>[][]): void { 52 | * const vals = new Map(); 53 | * for (const layer of layers) { 54 | * for (const node of layer) { 55 | * const { data } = node; 56 | * const val = data.role === "node" ? data.node.data.ord ? (data.link.source.data.ord + data.link.target.data.ord) / 2; 57 | * vals.set(node, val); 58 | * } 59 | * } 60 | * for (const layer of layers) { 61 | * layer.sort((a, b) => vals.get(a)! - vals.get(b)!); 62 | * } 63 | * } 64 | * ``` 65 | */ 66 | export interface Decross { 67 | /** 68 | * remove crossings from a layered graph 69 | * 70 | * @param layers - the layers of nodes that this should rearrange. 71 | */ 72 | (layers: SugiNode[][]): void; 73 | } 74 | -------------------------------------------------------------------------------- /src/sugiyama/decross/opt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { createLayers, getIndex } from "../test-utils"; 3 | import { crossings } from "../utils"; 4 | import { decrossOpt } from "./opt"; 5 | 6 | // NOTE optimal decrossing minimization can always be flipped, so there's no 7 | // way to guarantee a specific orientation 8 | 9 | test("decrossOpt() allows setting options", () => { 10 | const decross = decrossOpt().check("oom").dist(true); 11 | expect(decross.check()).toEqual("oom"); 12 | expect(decross.dist()).toBeTruthy(); 13 | }); 14 | 15 | test("decrossOpt() optimizes multiple links", () => { 16 | // minimizes multi-edges, although these shouldn't be possible in normal 17 | // operation, weighted edges might show up eventually 18 | // o o o o 19 | // |XX| ||x|| 20 | // o o -> o o 21 | // | | | | 22 | // o o o o 23 | const layers = createLayers([ 24 | [ 25 | [0, 1, 1], 26 | [0, 0, 1], 27 | ], 28 | [[0], [1]], 29 | [[], []], 30 | ]); 31 | decrossOpt()(layers); 32 | const inds = layers.map((layer) => layer.map(getIndex)); 33 | expect(inds).toEqual([ 34 | [1, 0], 35 | [0, 1], 36 | [0, 1], 37 | ]); 38 | }); 39 | 40 | test("decrossOpt() keeps clique invariant", () => { 41 | // remains the same 42 | // o o 43 | // |X| 44 | // o o 45 | const layers = createLayers([ 46 | [ 47 | [0, 1], 48 | [0, 1], 49 | ], 50 | [[], []], 51 | ]); 52 | const decross = decrossOpt(); 53 | decross(layers); 54 | const inds = layers.map((layer) => layer.map(getIndex)); 55 | expect(inds).toEqual([ 56 | [0, 1], 57 | [0, 1], 58 | ]); 59 | }); 60 | 61 | test("decrossOpt() propagates to both layers", () => { 62 | // o o o o 63 | // X | | 64 | // o o -> o o 65 | // | | | | 66 | // o o o o 67 | const layers = createLayers([ 68 | [[1], [0]], 69 | [[0], [1]], 70 | [[], []], 71 | ]); 72 | const decross = decrossOpt(); 73 | decross(layers); 74 | const inds = layers.map((layer) => layer.map(getIndex)); 75 | expect(inds).toEqual([ 76 | [1, 0], 77 | [0, 1], 78 | [0, 1], 79 | ]); 80 | }); 81 | 82 | test("decrossOpt() is optimal", () => { 83 | // greedy optimization keeps this structure because it minimizes the top 84 | // before the bottom resulting in two crossings, but taking one crossing at 85 | // the top allows removing both at the bottom 86 | // o o o 87 | // |/|\| 88 | // o o o 89 | // X X 90 | // o o o 91 | const layers = createLayers([ 92 | [[0], [0, 1, 2], [2]], 93 | [[1], [0, 2], [1]], 94 | [[], [], []], 95 | ]).map((layer) => layer.reverse()); 96 | expect(crossings(layers)).toBeCloseTo(2); 97 | const decross = decrossOpt().check("oom"); 98 | expect(decross.check()).toEqual("oom"); 99 | decross(layers); 100 | expect(crossings(layers)).toBeCloseTo(1); 101 | }); 102 | 103 | test("decrossOpt() works for compact layers", () => { 104 | // ideally this would force a crossing in the center in order to undo two 105 | // crossing on the outside. The extra nodes on the side are for coverage 106 | // o o 107 | // x # 108 | // o # # 109 | // | # | 110 | // o # # 111 | // X # 112 | // o o 113 | const layers = createLayers([ 114 | [0n, 1n], 115 | [0n, 1n], 116 | [[1], [0], 2n], 117 | [0n, 1n, 2n], 118 | [[0], 1n, [2]], 119 | [0n, 1n, 2n], 120 | [[1], [0], 2n], 121 | [0n, 1n, []], 122 | [0n, 1n], 123 | [[], []], 124 | ]); 125 | const layout = decrossOpt(); 126 | layout(layers); 127 | const inds = layers.map((layer) => layer.map(getIndex)); 128 | expect(inds).toEqual([ 129 | [0, 1], 130 | [0, 1], 131 | [0, 1, 2], 132 | [1, 0, 2], 133 | [1, 0, 2], 134 | [1, 0, 2], 135 | [1, 0, 2], 136 | [0, 1, 2], 137 | [0, 1], 138 | [0, 1], 139 | ]); 140 | }); 141 | 142 | test("decrossOpt() does not optimize distance", () => { 143 | const layers = createLayers([[[0, 2]], [[], [], []]]); 144 | decrossOpt()(layers); 145 | const inds = layers.map((layer) => layer.map(getIndex)); 146 | expect(inds).toEqual([[0], [0, 1, 2]]); 147 | }); 148 | 149 | test("decrossOpt() can optimize distance", () => { 150 | const layers = createLayers([[[0, 2]], [[], [], []]]); 151 | decrossOpt().dist(true)(layers); 152 | const inds = layers.map((layer) => layer.map(getIndex)); 153 | // NOTE this is brittle in that 1 can be on either side 154 | expect(inds).toEqual([[0], [1, 0, 2]]); 155 | }); 156 | 157 | test("decrossOpt() can optimize complex distance", () => { 158 | // family tree style 159 | // ---o-- 160 | // / \ \ 161 | // o o o o o 162 | // |/ \ / 163 | // o o 164 | const layers = createLayers([ 165 | [[0, 3, 4]], 166 | [[], [0], [0, 1], [], [1]], 167 | [[], []], 168 | ]); 169 | decrossOpt().dist(true)(layers); 170 | const inds = layers.map((layer) => layer.map(getIndex)); 171 | expect(inds).toEqual([[0], [1, 2, 4, 0, 3], [0, 1]]); 172 | }); 173 | 174 | function indexNestedArray(len: number): number[][] { 175 | return [ 176 | ...Array(len) 177 | .fill(null) 178 | .map((_, i) => [i]), 179 | ]; 180 | } 181 | 182 | test("decrossOpt() fails for large inputs", () => { 183 | const layers = createLayers([ 184 | indexNestedArray(30), 185 | indexNestedArray(30), 186 | indexNestedArray(30), 187 | Array(30).fill([]), 188 | ]); 189 | expect(() => decrossOpt()(layers)).toThrow(`"oom"`); 190 | }); 191 | 192 | test("decrossOpt() fails for medium inputs", () => { 193 | const layers = createLayers([ 194 | indexNestedArray(20), 195 | indexNestedArray(20), 196 | indexNestedArray(20), 197 | Array(20).fill([]), 198 | ]); 199 | expect(() => decrossOpt()(layers)).toThrow(`"slow"`); 200 | }); 201 | 202 | test("decrossOpt() fails passing an arg to constructor", () => { 203 | // @ts-expect-error no args 204 | expect(() => decrossOpt(null)).toThrow("got arguments to decrossOpt"); 205 | }); 206 | -------------------------------------------------------------------------------- /src/sugiyama/decross/two-layer.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Decross } from "."; 3 | import { SugiNode } from "../sugify"; 4 | import { createLayers, getIndex } from "../test-utils"; 5 | import { twolayerAgg } from "../twolayer/agg"; 6 | import { twolayerOpt } from "../twolayer/opt"; 7 | import { decrossTwoLayer } from "./two-layer"; 8 | 9 | test("decrossTwoLayer() propagates to both layers", () => { 10 | // o o o o 11 | // X | | 12 | // o o -> o o 13 | // | | | | 14 | // o o o o 15 | const layers = createLayers([ 16 | [[1], [0]], 17 | [[0], [1]], 18 | [[], []], 19 | ]); 20 | decrossTwoLayer()(layers); 21 | const inds = layers.map((layer) => layer.map(getIndex)); 22 | expect(inds).toEqual([ 23 | [1, 0], 24 | [0, 1], 25 | [0, 1], 26 | ]); 27 | }); 28 | 29 | test("decrossTwoLayer() allows setting operators", () => { 30 | function order( 31 | above: SugiNode<{ order: number }>[], 32 | below: SugiNode<{ order: number }>[], 33 | topDown: boolean, 34 | ): void { 35 | const layer = topDown ? below : above; 36 | for (const _ of layer) { 37 | // noop 38 | } 39 | } 40 | function initOne(layers: SugiNode<{ init: boolean }>[][]) { 41 | for (const _ of layers) { 42 | // noop 43 | } 44 | } 45 | function initTwo(layers: SugiNode[][]) { 46 | for (const _ of layers) { 47 | // noop 48 | } 49 | } 50 | 51 | const init = decrossTwoLayer() satisfies Decross; 52 | const ordered = init.order(order); 53 | ordered satisfies Decross<{ order: number }, unknown>; 54 | // @ts-expect-error invalid data 55 | ordered satisfies Decross; 56 | 57 | const layout = ordered.inits([initOne, initTwo]); 58 | layout satisfies Decross<{ order: number; init: boolean }, null>; 59 | // @ts-expect-error invalid data 60 | layout satisfies Decross<{ order: number }, unknown>; 61 | 62 | const [first, second] = layout.inits(); 63 | expect(first satisfies typeof initOne).toBe(initOne); 64 | expect(second satisfies typeof initTwo).toBe(initTwo); 65 | expect(layout.order() satisfies typeof order).toBe(order); 66 | }); 67 | 68 | test("decrossTwoLayer() propagates down and up", () => { 69 | const layers = createLayers([ 70 | [[1], [1], [0], [1]], 71 | [[], []], 72 | ]); 73 | decrossTwoLayer()(layers); 74 | const inds = layers.map((layer) => layer.map(getIndex)); 75 | expect(inds).toEqual([ 76 | [2, 3, 1, 0], 77 | [0, 1], 78 | ]); 79 | }); 80 | 81 | test("decrossTwoLayer() can be set", () => { 82 | const layers = createLayers([ 83 | [[1], [0]], 84 | [[0], [1]], 85 | [[], []], 86 | ]); 87 | const twolayer = twolayerAgg(); 88 | const myInit = () => undefined; 89 | const decross = decrossTwoLayer().order(twolayer).passes(2).inits([myInit]); 90 | const [init] = decross.inits(); 91 | expect(init).toBe(myInit); 92 | expect(decross.order()).toBe(twolayer); 93 | expect(decross.passes()).toBe(2); 94 | decross(layers); 95 | const inds = layers.map((layer) => layer.map(getIndex)); 96 | expect(inds).toEqual([ 97 | [0, 1], 98 | [1, 0], 99 | [1, 0], 100 | ]); 101 | }); 102 | 103 | test("decrossTwoLayer() can be set with all built in methods", () => { 104 | const layers = createLayers([[[0]], [[]]]); 105 | const decross = decrossTwoLayer(); 106 | decross.order(twolayerAgg()); 107 | decross.order(twolayerOpt()); 108 | decross(layers); 109 | const inds = layers.map((layer) => layer.map(getIndex)); 110 | expect(inds).toEqual([[0], [0]]); 111 | }); 112 | 113 | test("decrossTwoLayer() can be set with no inits", () => { 114 | const layers = createLayers([[[0]], [[]]]); 115 | const decross = decrossTwoLayer().inits([]); 116 | expect(decross.inits()).toEqual([]); 117 | decross(layers); 118 | const inds = layers.map((layer) => layer.map(getIndex)); 119 | expect(inds).toEqual([[0], [0]]); 120 | }); 121 | 122 | test("decrossTwoLayer() fails passing 0 to passes", () => { 123 | expect(() => decrossTwoLayer().passes(0)).toThrow( 124 | "number of passes must be positive", 125 | ); 126 | }); 127 | 128 | test("decrossTwoLayer() fails passing an arg to constructor", () => { 129 | // @ts-expect-error no args 130 | expect(() => decrossTwoLayer(null)).toThrow( 131 | "got arguments to decrossTwoLayer", 132 | ); 133 | }); 134 | -------------------------------------------------------------------------------- /src/sugiyama/layering/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { layerSeparation } from "."; 3 | import { ccoz, doub, ex, multi, oh, square, zhere } from "../../test-graphs"; 4 | import { layeringLongestPath } from "./longest-path"; 5 | import { layeringSimplex } from "./simplex"; 6 | import { sizedSep } from "./test-utils"; 7 | import { layeringTopological } from "./topological"; 8 | 9 | for (const dat of [doub, ex, square, ccoz, multi, oh, zhere]) { 10 | for (const [name, layering] of [ 11 | ["simplex", layeringSimplex()], 12 | ["longest path top down", layeringLongestPath().topDown(true)], 13 | ["longest path bottom up", layeringLongestPath().topDown(false)], 14 | ["topological", layeringTopological()], 15 | ] as const) { 16 | for (const sep of [layerSeparation, sizedSep]) { 17 | test(`invariants apply to ${dat.name} layered by ${name} with ${sep.name}`, () => { 18 | const dag = dat(); 19 | 20 | const num = layering(dag, sep); 21 | expect(num).toBeGreaterThanOrEqual(0); 22 | }); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/sugiyama/layering/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A {@link Layering} for assigning nodes in a dag a non-negative layer. 3 | * {@link Rank} and {@link Group} allow specifying extra constraints on the 4 | * layout. 5 | * 6 | * @packageDocumentation 7 | */ 8 | import { Graph, GraphNode } from "../../graph"; 9 | import { Separation } from "../utils"; 10 | 11 | /** 12 | * a group assignment accessor 13 | * 14 | * A group accessor assigns specific nodes a group string. Layering 15 | * operators that take a group accessor should respect the convention that 16 | * nodes with the same group should have the same layer. 17 | */ 18 | export interface Group { 19 | /** 20 | * assign a group to a node 21 | * 22 | * @param node - the node to assign a group to 23 | * @returns group - the node's group, `undefined` if the node doesn't have a 24 | * group 25 | */ 26 | (node: GraphNode): string | undefined; 27 | } 28 | 29 | /** 30 | * default separation for layering 31 | * 32 | * This separation returns 1 between nodes, or zero if any is not a node. 33 | */ 34 | export function layerSeparation(upper?: GraphNode, lower?: GraphNode): number { 35 | return +!!(upper && lower); 36 | } 37 | 38 | /** 39 | * An operator for layering a graph. 40 | * 41 | * Layering operators take a graph and a {@link Separation} function `sep`, and 42 | * must assign every node in the graph a y-coordinate that respects `sep` along 43 | * edges in the graph. In general the coordinates should try to respect 44 | * the same order as returned by {@link Graph#topological} but it's not 45 | * required. This should also return the total "height" of the layout, such 46 | * that all nodes coordinates + `sep(node, undefined)` is less than height. 47 | * 48 | * @example 49 | * 50 | * The built-in layering operators should cover the majority of use cases, but 51 | * you may need to implement your own for custom layouts. 52 | * 53 | * We illistrate implementing a custom layering operator where the nodes are 54 | * already assigned their y-coordinate in their data. Note that this doesn't 55 | * respect `sep` and an appropriate layering should, so this won't work as is. 56 | * 57 | * ```ts 58 | * function exampleLayering(dag: Graph, sep: Separation): number { 59 | * // determine span of ys 60 | * let min = Infinity; 61 | * let max = -Infinity; 62 | * for (const node of dag) { 63 | * const y = node.y = node.data.y; 64 | * min = Math.min(min, y - sep(undefined, node)); 65 | * max = Math.max(max, y + sep(node, undefined)); 66 | * } 67 | * // assign ys 68 | * for (const node of dag) { 69 | * node.y = -= min; 70 | * } 71 | * return max - min; 72 | * } 73 | * ``` 74 | */ 75 | export interface Layering { 76 | /** 77 | * layer a graph 78 | * 79 | * After calling this, every node should have a `y` coordinate that satisfies 80 | * `sep`. 81 | * 82 | * @param graph - the graph to layer 83 | * @param sep - the minimum separation between nodes 84 | * @returns height - the height after layering 85 | */ 86 | ( 87 | graph: Graph, 88 | sep: Separation, 89 | ): number; 90 | 91 | /** 92 | * This sentinel field is so that typescript can infer the types of NodeDatum 93 | * and LinkDatum, because the extra generics make it otherwise hard to infer. 94 | * It's a function to keep the same variance. 95 | * 96 | * @internal 97 | */ 98 | __sentinel__?: (_: NodeDatum, __: LinkDatum) => void; 99 | } 100 | -------------------------------------------------------------------------------- /src/sugiyama/layering/longest-path.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Layering, layerSeparation } from "."; 3 | import { graphConnect } from "../../graph/connect"; 4 | import { ccoz, eye, multi, oh, square } from "../../test-graphs"; 5 | import { canonical, getLayers } from "../test-utils"; 6 | import { layeringLongestPath } from "./longest-path"; 7 | import { sizedSep } from "./test-utils"; 8 | 9 | const changes = [ 10 | ["0", "0"], 11 | ["1", "2"], 12 | ] as const; 13 | 14 | test("layeringLongestPath() works for square", () => { 15 | const dag = square(); 16 | const layering = layeringLongestPath(); 17 | const num = layering(dag, layerSeparation); 18 | expect(num).toBe(2); 19 | const layers = getLayers(dag, num + 1); 20 | expect([[0], [1, 2], [3]]).toEqual(layers); 21 | }); 22 | 23 | test("layeringLongestPath() works for square with sizedSep", () => { 24 | const dag = square(); 25 | const layering = layeringLongestPath(); 26 | const height = layering(dag, sizedSep); 27 | expect(height).toBeCloseTo(7); 28 | const [zero, one, two, three] = canonical(dag); 29 | expect(zero.y).toBeCloseTo(0.5); 30 | expect(one.y).toBeCloseTo(3); 31 | expect(two.y).toBeCloseTo(3.5); 32 | expect(three.y).toBeCloseTo(6.5); 33 | }); 34 | 35 | test("layeringLongestPath() works for square with sizedSep bottom up", () => { 36 | const dag = square(); 37 | const layering = layeringLongestPath().topDown(false); 38 | const height = layering(dag, sizedSep); 39 | expect(height).toBeCloseTo(7); 40 | const [zero, one, two, three] = canonical(dag); 41 | expect(zero.y).toBeCloseTo(0.5); 42 | expect(one.y).toBeCloseTo(4); 43 | expect(two.y).toBeCloseTo(3.5); 44 | expect(three.y).toBeCloseTo(6.5); 45 | }); 46 | 47 | test("layeringLongestPath() works for disconnected graph", () => { 48 | const dag = ccoz(); 49 | const layering = layeringLongestPath(); 50 | const num = layering(dag, layerSeparation); 51 | const layers = getLayers(dag, num + 1); 52 | expect(layers.length).toBeTruthy(); 53 | }); 54 | 55 | test("layeringLongestPath() works for disconnected graph with sizedSep", () => { 56 | const dag = ccoz(); 57 | const layering = layeringLongestPath(); 58 | const height = layering(dag, sizedSep); 59 | expect(height).toBeCloseTo(7); 60 | }); 61 | 62 | test("layeringLongestPath() works for cyclic graph", () => { 63 | const dag = oh(); 64 | const layering = layeringLongestPath(); 65 | const num = layering(dag, layerSeparation); 66 | const layers = getLayers(dag, num + 1); 67 | expect([ 68 | [[0], [1]], 69 | [[1], [0]], 70 | ]).toContainEqual(layers); 71 | }); 72 | 73 | test("layeringLongestPath() works for rank", () => { 74 | function rank({ data }: { data: string }): number { 75 | return -parseInt(data); 76 | } 77 | 78 | const dag = square(); 79 | const base = layeringLongestPath() satisfies Layering; 80 | const layering = base.rank(rank) satisfies Layering; 81 | // @ts-expect-error no longer general 82 | layering satisfies Layering; 83 | expect(layering.rank() satisfies typeof rank).toBe(rank); 84 | 85 | const num = layering(dag, layerSeparation); 86 | expect(num).toBe(2); 87 | const layers = getLayers(dag, num + 1); 88 | expect([[3], [1, 2], [0]]).toEqual(layers); 89 | }); 90 | 91 | test("layeringLongestPath() works for topDown", () => { 92 | const create = graphConnect().single(true); 93 | const dag = create(changes); 94 | const layering = layeringLongestPath(); 95 | expect(layering.topDown()).toBeTruthy(); 96 | const num = layering(dag, layerSeparation); 97 | expect(num).toBe(1); 98 | const layers = getLayers(dag, num + 1); 99 | expect([[1], [0, 2]]).toEqual(layers); 100 | }); 101 | 102 | test("layeringLongestPath() works for bottomUp", () => { 103 | const create = graphConnect().single(true); 104 | const dag = create(changes); 105 | const layering = layeringLongestPath().topDown(false); 106 | expect(layering.topDown()).toBeFalsy(); 107 | const num = layering(dag, layerSeparation); 108 | expect(num).toBe(1); 109 | const layers = getLayers(dag, num + 1); 110 | expect([[0, 1], [2]]).toEqual(layers); 111 | }); 112 | 113 | test("layeringLongestPath() compacts long edges", () => { 114 | const builder = graphConnect(); 115 | const dag = builder([ 116 | ["0", "1"], 117 | ["0", "2"], 118 | ["2", "3"], 119 | ["4", "3"], 120 | ]); 121 | const layering = layeringLongestPath(); 122 | const num = layering(dag, layerSeparation); 123 | expect(num).toBe(2); 124 | const layers = getLayers(dag, num + 1); 125 | expect([[0], [1, 2, 4], [3]]).toEqual(layers); 126 | }); 127 | 128 | test("layeringLongestPath() compacts multiple long edges", () => { 129 | const builder = graphConnect(); 130 | const dag = builder([ 131 | ["0", "1"], 132 | ["1", "2"], 133 | ["0", "3"], 134 | ["3", "4"], 135 | ["4", "5"], 136 | ["6", "5"], 137 | ["7", "6"], 138 | ]); 139 | const layering = layeringLongestPath(); 140 | const num = layering(dag, layerSeparation); 141 | expect(num).toBe(3); 142 | const layers = getLayers(dag, num + 1); 143 | expect([[0], [1, 3, 7], [2, 4, 6], [5]]).toEqual(layers); 144 | }); 145 | 146 | test("layeringLongestPath() works for multi dag", () => { 147 | const dag = multi(); 148 | const layering = layeringLongestPath(); 149 | const num = layering(dag, layerSeparation); 150 | expect(num).toBe(1); 151 | const layers = getLayers(dag, num + 1); 152 | expect([[0], [1]]).toEqual(layers); 153 | }); 154 | 155 | test("layeringLongestPath() works for multi dag bottom up", () => { 156 | const dag = multi(); 157 | const layering = layeringLongestPath().topDown(false); 158 | const num = layering(dag, layerSeparation); 159 | expect(num).toBe(1); 160 | const layers = getLayers(dag, num + 1); 161 | expect([[0], [1]]).toEqual(layers); 162 | }); 163 | 164 | test("layeringLongestPath() works for eye multi dag", () => { 165 | const dag = eye(); 166 | const layering = layeringLongestPath(); 167 | const num = layering(dag, layerSeparation); 168 | expect(num).toBe(2); 169 | const layers = getLayers(dag, num + 1); 170 | expect([[0], [1], [2]]).toEqual(layers); 171 | }); 172 | 173 | test("layeringLongestPath() works for eye multi dag bottom up", () => { 174 | const dag = eye(); 175 | const layering = layeringLongestPath().topDown(false); 176 | const num = layering(dag, layerSeparation); 177 | expect(num).toBe(2); 178 | const layers = getLayers(dag, num + 1); 179 | expect([[0], [1], [2]]).toEqual(layers); 180 | }); 181 | 182 | test("layeringLongestPath() fails passing an arg to constructor", () => { 183 | // @ts-expect-error no args 184 | expect(() => layeringLongestPath(null)).toThrow( 185 | "got arguments to layeringLongestPath", 186 | ); 187 | }); 188 | -------------------------------------------------------------------------------- /src/sugiyama/layering/longest-path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A {@link LayeringLongestPath} that minimizes the height of the final layout 3 | * 4 | * @packageDocumentation 5 | */ 6 | import { Layering } from "."; 7 | import { Graph, Rank } from "../../graph"; 8 | import { chain, filter, map } from "../../iters"; 9 | import { U, err } from "../../utils"; 10 | import { Separation } from "../utils"; 11 | 12 | /** longest path operators */ 13 | export interface LayeringLongestPathOps { 14 | /** rank operator */ 15 | rank: Rank; 16 | } 17 | 18 | /** the node datum of a set of operators */ 19 | type OpsNodeDatum = 20 | Ops extends LayeringLongestPathOps ? N : never; 21 | 22 | /** the link datum of a set of operators */ 23 | type OpsLinkDatum = 24 | Ops extends LayeringLongestPathOps ? L : never; 25 | 26 | /** 27 | * a {@link Layering} that minimizes the height of the final layout. 28 | * 29 | * This often results in very wide and unpleasing graphs, but is very fast. The 30 | * layout can go {@link topDown | top-down} or bottom-up, either assigning all roots to layer 0 31 | * or all leaves to the last layer. 32 | * 33 | * Create with {@link layeringLongestPath}. 34 | */ 35 | export interface LayeringLongestPath< 36 | Ops extends LayeringLongestPathOps = LayeringLongestPathOps, 37 | > extends Layering, OpsLinkDatum> { 38 | /** 39 | * set the {@link Rank} 40 | * 41 | * The rank will override the default ordering of nodes for rending top to 42 | * bottom. Note that unlike {@link layeringSimplex} nodes with the same rank 43 | * are *not* guaranteed to be on the same layer. 44 | */ 45 | rank( 46 | newRank: NewRank, 47 | ): LayeringLongestPath>; 48 | /** 49 | * get the current {@link Rank}. 50 | */ 51 | rank(): Ops["rank"]; 52 | 53 | /** 54 | * set whether longest path should go top down 55 | * 56 | * If set to true, the longest path will start at the top, putting nodes as 57 | * close to the top as possible. 58 | * 59 | * (default: `true`) 60 | */ 61 | topDown(val: boolean): LayeringLongestPath; 62 | /** get whether or not this is using topDown. */ 63 | topDown(): boolean; 64 | 65 | /** @internal flag indicating that this is built in to d3dag and shouldn't error in specific instances */ 66 | readonly d3dagBuiltin: true; 67 | } 68 | 69 | function buildOperator>( 70 | ops: O & LayeringLongestPathOps, 71 | options: { topDown: boolean }, 72 | ): LayeringLongestPath { 73 | function layeringLongestPath( 74 | dag: Graph, 75 | sep: Separation, 76 | ): number { 77 | let height = 0; 78 | const nodes = dag.topological(ops.rank); 79 | 80 | // clear ys to indicate previously assigned nodes 81 | for (const node of nodes) { 82 | node.uy = undefined; 83 | } 84 | 85 | // flip if we're top down 86 | if (options.topDown) { 87 | nodes.reverse(); 88 | } 89 | 90 | // progressively update y 91 | for (const node of nodes) { 92 | const val = Math.max( 93 | sep(undefined, node), 94 | ...map( 95 | chain(node.parents(), node.children()), 96 | (c) => (c.uy ?? -Infinity) + sep(c, node), 97 | ), 98 | ); 99 | height = Math.max(height, val + sep(node, undefined)); 100 | node.y = val; 101 | } 102 | 103 | // go in reverse an update y in case we can shrink long edges 104 | for (const node of nodes.reverse()) { 105 | const val = Math.min( 106 | ...map( 107 | filter(chain(node.parents(), node.children()), (c) => node.y < c.y), 108 | (c) => c.y - sep(c, node), 109 | ), 110 | ); 111 | 112 | // don't update position if node has no "children" on this pass 113 | if (val !== Infinity) { 114 | node.y = val; 115 | } 116 | } 117 | 118 | // flip again if we're top down 119 | if (options.topDown) { 120 | for (const node of nodes) { 121 | node.y = height - node.y; 122 | } 123 | } 124 | 125 | return height; 126 | } 127 | 128 | function rank( 129 | newRank: NR, 130 | ): LayeringLongestPath>; 131 | function rank(): O["rank"]; 132 | function rank( 133 | newRank?: NR, 134 | ): LayeringLongestPath> | O["rank"] { 135 | if (newRank === undefined) { 136 | return ops.rank; 137 | } else { 138 | const { rank: _, ...rest } = ops; 139 | return buildOperator({ ...rest, rank: newRank }, options); 140 | } 141 | } 142 | layeringLongestPath.rank = rank; 143 | 144 | function topDown(): boolean; 145 | function topDown(val: boolean): LayeringLongestPath; 146 | function topDown(val?: boolean): boolean | LayeringLongestPath { 147 | if (val === undefined) { 148 | return options.topDown; 149 | } else { 150 | return buildOperator(ops, { 151 | ...options, 152 | topDown: val, 153 | }); 154 | } 155 | } 156 | layeringLongestPath.topDown = topDown; 157 | 158 | layeringLongestPath.d3dagBuiltin = true as const; 159 | 160 | return layeringLongestPath; 161 | } 162 | 163 | function defaultAccessor(): undefined { 164 | return undefined; 165 | } 166 | 167 | /** default longest path operator */ 168 | export type DefaultLayeringLongestPath = LayeringLongestPath<{ 169 | /** unconstrained rank */ 170 | rank: Rank; 171 | }>; 172 | 173 | /** 174 | * create a default {@link LayeringLongestPath} 175 | * 176 | * This {@link Layering} operator minimizes the height of the final layout. 177 | * This often results in very wide and unpleasing graphs, but is very fast. You 178 | * can set if it goes {@link LayeringLongestPath#topDown}. 179 | * 180 | * @example 181 | * 182 | * ```ts 183 | * const layout = sugiyama().layering(layeringLongestPath().topDown(false)); 184 | * ``` 185 | */ 186 | export function layeringLongestPath( 187 | ...args: never[] 188 | ): DefaultLayeringLongestPath { 189 | if (args.length) { 190 | throw err`got arguments to layeringLongestPath(${args}); you probably forgot to construct layeringLongestPath before passing to layering: \`sugiyama().layering(layeringLongestPath())\`, note the trailing "()"`; 191 | } 192 | return buildOperator({ rank: defaultAccessor }, { topDown: true }); 193 | } 194 | -------------------------------------------------------------------------------- /src/sugiyama/layering/simplex.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Layering, layerSeparation } from "."; 3 | import { GraphNode } from "../../graph"; 4 | import { graphConnect } from "../../graph/connect"; 5 | import { doub, ex, eye, multi, oh, square } from "../../test-graphs"; 6 | import { canonical, getLayers } from "../test-utils"; 7 | import { layeringSimplex as simplex } from "./simplex"; 8 | import { sizedSep } from "./test-utils"; 9 | 10 | test("simplex() works for square", () => { 11 | const dag = square(); 12 | const layering = simplex(); 13 | const num = layering(dag, layerSeparation); 14 | expect(num).toBe(2); 15 | const layers = getLayers(dag, num + 1); 16 | expect(layers).toEqual([[0], [1, 2], [3]]); 17 | }); 18 | 19 | test("simplex() works for square with sizedSep", () => { 20 | const dag = square(); 21 | const layering = simplex(); 22 | const height = layering(dag, sizedSep); 23 | expect(height).toBe(7); 24 | const [zero, one, two, three] = canonical(dag); 25 | expect(zero.y).toBeCloseTo(0.5); 26 | expect(one.y).toBeGreaterThanOrEqual(3); 27 | expect(one.y).toBeLessThanOrEqual(4); 28 | expect(two.y).toBeCloseTo(3.5); 29 | expect(three.y).toBeCloseTo(6.5); 30 | }); 31 | 32 | test("simplex() works for known failure", () => { 33 | const create = graphConnect(); 34 | const dag = create([ 35 | ["0", "1"], 36 | ["1", "2"], 37 | ["2", "3"], 38 | ["3", "4"], 39 | ["5", "4"], 40 | ["6", "4"], 41 | ]); 42 | const layering = simplex(); 43 | const num = layering(dag, layerSeparation); 44 | expect(num).toBe(4); 45 | const nodes = canonical(dag); 46 | for (const [i, node] of nodes.entries()) { 47 | expect(node.y).toBeCloseTo(i < 5 ? i : 3); 48 | } 49 | }); 50 | 51 | test("simplex() respects ranks and gets them", () => { 52 | const dag = square(); 53 | function ranker({ data }: { data: string }): undefined | number { 54 | if (data === "1") { 55 | return 1; 56 | } else if (data === "2") { 57 | return 2; 58 | } else { 59 | return undefined; 60 | } 61 | } 62 | 63 | function group(node: GraphNode): undefined { 64 | for (const _ of node.childLinks()) { 65 | // noop 66 | } 67 | return undefined; 68 | } 69 | 70 | const init = simplex() satisfies Layering; 71 | 72 | const grouped = init.group(group); 73 | grouped satisfies Layering; 74 | // @ts-expect-error invalid data 75 | grouped satisfies Layering; 76 | 77 | const layering = grouped.rank(ranker); 78 | layering satisfies Layering; 79 | // @ts-expect-error invalid data 80 | layering satisfies Layering; 81 | 82 | expect(layering.rank() satisfies typeof ranker).toBe(ranker); 83 | expect(layering.group() satisfies typeof group).toBe(group); 84 | 85 | const num = layering(dag, layerSeparation); 86 | expect(num).toBe(3); 87 | const layers = getLayers(dag, num + 1); 88 | expect(layers).toEqual([[0], [1], [2], [3]]); 89 | }); 90 | 91 | test("simplex() works for X", () => { 92 | // NOTE longest path will always produce a dummy node, where simplex 93 | // will not 94 | const dag = ex(); 95 | const layering = simplex(); 96 | const num = layering(dag, layerSeparation); 97 | expect(num).toBe(4); 98 | const layers = getLayers(dag, num + 1); 99 | expect(layers).toEqual([[0], [1, 2], [3], [4, 5], [6]]); 100 | }); 101 | 102 | test("simplex() respects equality rank", () => { 103 | const dag = ex(); 104 | const layering = simplex().rank((node: GraphNode) => 105 | node.data === "0" || node.data === "2" ? 0 : undefined, 106 | ); 107 | const num = layering(dag, layerSeparation); 108 | expect(num).toBe(4); 109 | const layers = getLayers(dag, num + 1); 110 | expect(layers).toEqual([[0, 2], [1], [3], [4, 5], [6]]); 111 | }); 112 | 113 | test("simplex() respects groups", () => { 114 | const dag = ex(); 115 | const grp = (node: GraphNode) => 116 | node.data === "0" || node.data === "2" ? "group" : undefined; 117 | const layering = simplex().group(grp); 118 | expect(layering.group()).toBe(grp); 119 | const num = layering(dag, layerSeparation); 120 | expect(num).toBe(4); 121 | const layers = getLayers(dag, num + 1); 122 | expect([[0, 2], [1], [3], [4, 5], [6]]).toEqual(layers); 123 | }); 124 | 125 | test("simplex() works for disconnected dag", () => { 126 | const dag = doub(); 127 | const layering = simplex(); 128 | const num = layering(dag, layerSeparation); 129 | expect(num).toBe(0); 130 | const layers = getLayers(dag, num + 1); 131 | expect([[0, 1]]).toEqual(layers); 132 | }); 133 | 134 | test("simplex() works for disconnected dag with sizedSep", () => { 135 | const dag = doub(); 136 | const layering = simplex(); 137 | const height = layering(dag, sizedSep); 138 | expect(height).toBeCloseTo(2); 139 | }); 140 | 141 | test("simplex() works for multi dag", () => { 142 | const dag = multi(); 143 | const layering = simplex(); 144 | const num = layering(dag, layerSeparation); 145 | expect(num).toBe(1); 146 | const layers = getLayers(dag, num + 1); 147 | expect([[0], [1]]).toEqual(layers); 148 | }); 149 | 150 | test("simplex() works for eye multi dag", () => { 151 | const dag = eye(); 152 | const layering = simplex(); 153 | const num = layering(dag, layerSeparation); 154 | expect(num).toBe(2); 155 | const layers = getLayers(dag, num + 1); 156 | expect([[0], [1], [2]]).toEqual(layers); 157 | }); 158 | 159 | test("simplex() works for oh", () => { 160 | const dag = oh(); 161 | const layering = simplex(); 162 | const num = layering(dag, layerSeparation); 163 | expect(num).toBe(1); 164 | const layers = getLayers(dag, num + 1); 165 | expect([ 166 | [[0], [1]], 167 | [[1], [0]], 168 | ]).toContainEqual(layers); 169 | }); 170 | 171 | test("simplex() works for oh with sizedSep", () => { 172 | const dag = oh(); 173 | const layering = simplex(); 174 | const height = layering(dag, sizedSep); 175 | expect(height).toBe(4); 176 | const [zero, one] = canonical(dag); 177 | // NOTE could flip 178 | expect(zero.y).toBeCloseTo(3.5); 179 | expect(one.y).toBeCloseTo(1); 180 | }); 181 | 182 | test("simplex() fails passing an arg to constructor", () => { 183 | // @ts-expect-error no args 184 | expect(() => simplex(null)).toThrow("got arguments to layeringSimplex"); 185 | }); 186 | 187 | test("simplex() fails with ill-defined groups", () => { 188 | const dag = square(); 189 | const layout = simplex().group((node: GraphNode) => { 190 | return node.data === "0" || node.data === "1" ? "" : undefined; 191 | }); 192 | expect(() => layout(dag, layerSeparation)).toThrow( 193 | "could not find a feasible simplex layout", 194 | ); 195 | }); 196 | 197 | test("simplex() fails with ill-defined group", () => { 198 | const dag = square(); 199 | const layout = simplex().group((node: GraphNode) => 200 | node.data === "0" || node.data === "3" ? "group" : undefined, 201 | ); 202 | expect(() => layout(dag, layerSeparation)).toThrow( 203 | "could not find a feasible simplex layout", 204 | ); 205 | }); 206 | -------------------------------------------------------------------------------- /src/sugiyama/layering/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { GraphNode } from "../../graph"; 2 | import { Separation, sizedSeparation } from "../utils"; 3 | 4 | function nodeHeight({ data }: GraphNode): number { 5 | return (parseInt(data) % 3) + 1; 6 | } 7 | 8 | /** default sized separation */ 9 | export const sizedSep: Separation = sizedSeparation( 10 | nodeHeight, 11 | 1, 12 | ); 13 | -------------------------------------------------------------------------------- /src/sugiyama/layering/topological.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Layering, layerSeparation } from "."; 3 | import { doub, eye, multi, oh, square } from "../../test-graphs"; 4 | import { canonical, getLayers } from "../test-utils"; 5 | import { sizedSep } from "./test-utils"; 6 | import { layeringTopological } from "./topological"; 7 | 8 | test("layeringTopological() works for square", () => { 9 | const dag = square(); 10 | const layering = layeringTopological(); 11 | const num = layering(dag, layerSeparation); 12 | expect(num).toBe(3); 13 | const layers = getLayers(dag, num + 1); 14 | expect([ 15 | [[0], [1], [2], [3]], 16 | [[0], [2], [1], [3]], 17 | ]).toContainEqual(layers); 18 | }); 19 | 20 | test("layeringTopological() works for square with sizedSep", () => { 21 | const dag = square(); 22 | const layering = layeringTopological(); 23 | const height = layering(dag, sizedSep); 24 | expect(height).toBeCloseTo(10); 25 | // NOTE 1 & 2 could flip 26 | const [zero, one, two, three] = canonical(dag); 27 | expect(zero.y).toBeCloseTo(0.5); 28 | expect(one.y).toBeCloseTo(3); 29 | expect(two.y).toBeCloseTo(6.5); 30 | expect(three.y).toBeCloseTo(9.5); 31 | }); 32 | 33 | test("layeringTopological() allows setting rank", () => { 34 | const dag = square(); 35 | const rank = ({ data }: { data: string }) => -parseInt(data); 36 | 37 | const init = layeringTopological() satisfies Layering; 38 | 39 | const layering = init.rank(rank) satisfies Layering; 40 | // @ts-expect-error invalid data 41 | layering satisfies Layering; 42 | 43 | expect(layering.rank() satisfies typeof rank).toBe(rank); 44 | 45 | const num = layering(dag, layerSeparation); 46 | expect(num).toBe(3); 47 | const layers = getLayers(dag, num + 1); 48 | expect([ 49 | [[3], [2], [1], [0]], 50 | [[3], [1], [2], [0]], 51 | ]).toContainEqual(layers); 52 | }); 53 | 54 | test("layeringTopological() works for disconnected graph", () => { 55 | const dag = doub(); 56 | const layering = layeringTopological(); 57 | const num = layering(dag, layerSeparation); 58 | expect(num).toBe(1); 59 | const layers = getLayers(dag, num + 1); 60 | // NOTE implementation dependent 61 | expect(layers).toEqual([[0], [1]]); 62 | }); 63 | 64 | test("layeringTopological() works for disconnected graph with sizedSep", () => { 65 | const dag = doub(); 66 | const layering = layeringTopological(); 67 | const height = layering(dag, sizedSep); 68 | expect(height).toBe(4); 69 | }); 70 | 71 | test("layeringTopological() works for multi graph", () => { 72 | const dag = multi(); 73 | const layering = layeringTopological(); 74 | const num = layering(dag, layerSeparation); 75 | expect(num).toBe(1); 76 | const layers = getLayers(dag, num + 1); 77 | expect(layers).toEqual([[0], [1]]); 78 | }); 79 | 80 | test("layeringTopological() works for eye multi graph", () => { 81 | const dag = eye(); 82 | const layering = layeringTopological(); 83 | const num = layering(dag, layerSeparation); 84 | expect(num).toBe(2); 85 | const layers = getLayers(dag, num + 1); 86 | expect([[0], [1], [2]]).toEqual(layers); 87 | }); 88 | 89 | test("layeringTopological() works for cyclic graph", () => { 90 | const dag = oh(); 91 | const layering = layeringTopological(); 92 | const num = layering(dag, layerSeparation); 93 | expect(num).toBe(1); 94 | const layers = getLayers(dag, num + 1); 95 | // NOTE implementation dependent 96 | expect([ 97 | [[0], [1]], 98 | [[1], [0]], 99 | ]).toContainEqual(layers); 100 | }); 101 | 102 | test("layeringTopological() fails passing an arg to constructor", () => { 103 | // @ts-expect-error no args 104 | expect(() => layeringTopological(null)).toThrow( 105 | "got arguments to layeringTopological", 106 | ); 107 | }); 108 | -------------------------------------------------------------------------------- /src/sugiyama/layering/topological.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A {@link LayeringTopological} that assigns each node a unique layer. 3 | * 4 | * @packageDocumentation 5 | */ 6 | import { Layering } from "."; 7 | import { Graph, Rank } from "../../graph"; 8 | import { U, err } from "../../utils"; 9 | import { Separation } from "../utils"; 10 | 11 | /** topological operators */ 12 | export interface LayeringTopologicalOps { 13 | /** rank operator */ 14 | rank: Rank; 15 | } 16 | 17 | /** the node datum of a set of operators */ 18 | type OpsNodeDatum = 19 | Ops extends LayeringTopologicalOps ? N : never; 20 | 21 | /** the link datum of a set of operators */ 22 | type OpsLinkDatum = 23 | Ops extends LayeringTopologicalOps ? L : never; 24 | 25 | /** 26 | * a layering that assigns every node a distinct layer 27 | * 28 | * This combined with topological coordinate assignment can be thought of as an 29 | * alternative to {@link zherebko}. The latter generally produces more pleasing 30 | * layouts, but both are options. This layering is very fast, but it may make 31 | * other steps take longer due to the many created dummy nodes. 32 | * 33 | * Create with {@link layeringTopological}. 34 | */ 35 | export interface LayeringTopological< 36 | Ops extends LayeringTopologicalOps = LayeringTopologicalOps, 37 | > extends Layering, OpsLinkDatum> { 38 | /** 39 | * set the {@link Rank} 40 | * 41 | * Nodes will first be in rank order, and then in topological order 42 | * attempting to minimize edge inversions. 43 | */ 44 | rank( 45 | newRank: NewRank, 46 | ): LayeringTopological>; 47 | /** 48 | * get the current {@link Rank}. 49 | */ 50 | rank(): Ops["rank"]; 51 | 52 | /** @internal flag indicating that this is built in to d3dag and shouldn't error in specific instances */ 53 | readonly d3dagBuiltin: true; 54 | } 55 | 56 | /** 57 | * Create a topological layering. 58 | */ 59 | function buildOperator>( 60 | options: Ops & LayeringTopologicalOps, 61 | ): LayeringTopological { 62 | function layeringTopological( 63 | dag: Graph, 64 | sep: Separation, 65 | ): number { 66 | let height = 0; 67 | let last; 68 | for (const node of dag.topological(options.rank)) { 69 | height += sep(last, node); 70 | node.y = height; 71 | last = node; 72 | } 73 | height += sep(last, undefined); 74 | return height; 75 | } 76 | 77 | function rank( 78 | newRank: NR, 79 | ): LayeringTopological>; 80 | function rank(): Ops["rank"]; 81 | function rank( 82 | newRank?: NR, 83 | ): LayeringTopological> | Ops["rank"] { 84 | if (newRank === undefined) { 85 | return options.rank; 86 | } else { 87 | const { rank: _, ...rest } = options; 88 | return buildOperator({ ...rest, rank: newRank }); 89 | } 90 | } 91 | layeringTopological.rank = rank; 92 | 93 | layeringTopological.d3dagBuiltin = true as const; 94 | 95 | return layeringTopological; 96 | } 97 | 98 | function defaultAccessor(): undefined { 99 | return undefined; 100 | } 101 | 102 | /** default topological operator */ 103 | export type DefaultLayeringTopological = LayeringTopological<{ 104 | /** unconstrained rank */ 105 | rank: Rank; 106 | }>; 107 | 108 | /** 109 | * create a default {@link LayeringTopological} 110 | * 111 | * This is a layering that assigns every node to a distinct layer. 112 | * 113 | * @example 114 | * 115 | * ```ts 116 | * const layout = sugiyama().layering(layeringTopological()); 117 | * ``` 118 | */ 119 | export function layeringTopological( 120 | ...args: never[] 121 | ): DefaultLayeringTopological { 122 | if (args.length) { 123 | throw err`got arguments to layeringTopological(${args}); you probably forgot to construct layeringTopological before passing to layering: \`sugiyama().layering(layeringTopological())\`, note the trailing "()"`; 124 | } 125 | return buildOperator({ rank: defaultAccessor }); 126 | } 127 | -------------------------------------------------------------------------------- /src/sugiyama/test-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * utilities for testing 3 | * 4 | * @internal 5 | * @packageDocumentation 6 | */ 7 | import { 8 | Graph, 9 | graph, 10 | GraphNode, 11 | MutGraph, 12 | MutGraphLink, 13 | MutGraphNode, 14 | } from "../graph"; 15 | import { entries, map, reverse } from "../iters"; 16 | import { assert } from "../test-utils"; 17 | import { SugiLinkDatum, SugiNode, SugiNodeDatum } from "./sugify"; 18 | 19 | interface MutSugiNodeDatum extends SugiNodeDatum { 20 | node: MutGraphNode; 21 | } 22 | 23 | interface MutSugiLinkDatum extends SugiLinkDatum { 24 | link: MutGraphLink; 25 | } 26 | 27 | type MutSugiDatum = MutSugiNodeDatum | MutSugiLinkDatum; 28 | 29 | export function getLayers(dag: Graph, numLayers: number): number[][] { 30 | const layers: number[][] = Array(numLayers) 31 | .fill(null) 32 | .map(() => []); 33 | for (const node of dag.nodes()) { 34 | layers[node.y].push(parseInt(node.data)); 35 | } 36 | for (const layer of layers) { 37 | layer.sort((a, b) => a - b); 38 | } 39 | return layers; 40 | } 41 | 42 | interface IndexDatum { 43 | index: number; 44 | } 45 | 46 | export function getIndex({ data }: SugiNode): number | null { 47 | return data.role === "node" ? data.node.data.index : null; 48 | } 49 | 50 | export interface TestDatum { 51 | layer: number; 52 | index: number; 53 | } 54 | 55 | export function createLayers( 56 | children: (number[] | number | bigint)[][], 57 | ): SugiNode[][] { 58 | // easy checks 59 | assert(children.length); 60 | for (const first of children[0]) { 61 | assert(typeof first !== "number"); 62 | } 63 | for (const last of children[children.length - 1]) { 64 | assert(typeof last === "object" && !last.length); 65 | } 66 | 67 | const orig: MutGraph = graph(); 68 | const sugi = graph, undefined>(); 69 | const layers = children.map((layer) => 70 | Array, undefined>>( 71 | layer.length, 72 | ), 73 | ); 74 | 75 | // create original nodes and corresponding sugi nodes 76 | for (const [layer, layChildren] of children.entries()) { 77 | for (const [index, val] of layChildren.entries()) { 78 | if (typeof val === "object") { 79 | const node = orig.node({ layer, index }); 80 | layers[layer][index] = sugi.node({ 81 | role: "node", 82 | node, 83 | topLayer: layer, 84 | bottomLayer: layer, 85 | }); 86 | } 87 | } 88 | } 89 | 90 | // replicate nodes upward 91 | for (const [ilay, layChildren] of entries(reverse(children))) { 92 | const layer = children.length - ilay; 93 | for (const [index, child] of layChildren.entries()) { 94 | if (typeof child === "bigint") { 95 | const lay = layer - 1; 96 | // TODO remove number cast when ts is upgraded 97 | const sugi = layers[layer][Number(child)]; 98 | const { data } = sugi; 99 | assert(data.role === "node"); 100 | data.topLayer = lay; 101 | layers[lay][index] = sugi; 102 | } 103 | } 104 | } 105 | 106 | // create dummy nodes 107 | // NOTE copy nodes because order will change as links are added 108 | for (const node of [...orig.nodes()]) { 109 | const { layer, index } = node.data; 110 | const sugiSource = layers[layer][index]; 111 | const inds = children[layer][index]; 112 | const initLayer = layer; 113 | assert(typeof inds === "object"); 114 | for (let index of inds) { 115 | let lay = initLayer + 1; 116 | let next; 117 | const queue = []; 118 | while (typeof (next = children[lay][index]) === "number") { 119 | queue.push(index); 120 | lay++; 121 | index = next; 122 | } 123 | const sugiTarget = layers[lay][index]; 124 | assert(sugiTarget.data.role === "node"); 125 | const target = sugiTarget.data.node; 126 | const link = node.child(target, undefined); 127 | 128 | let last = sugiSource; 129 | lay = initLayer + 1; 130 | for (const index of queue) { 131 | const sug = sugi.node({ link, layer: lay, role: "link" }); 132 | layers[lay][index] = sug; 133 | last.child(sug, undefined); 134 | last = sug; 135 | lay++; 136 | } 137 | last.child(sugiTarget, undefined); 138 | } 139 | } 140 | 141 | // set y 142 | for (const [y, layer] of layers.entries()) { 143 | for (const node of layer) { 144 | node.y = y; 145 | } 146 | } 147 | 148 | return layers; 149 | } 150 | 151 | /** return true if there are any compact crossings */ 152 | export function compactCrossings( 153 | topLayer: readonly SugiNode[], 154 | bottomLayer: readonly SugiNode[], 155 | ): boolean { 156 | // compute partition of compact and non-compact nodes 157 | const bottomInds = new Map(map(bottomLayer, (node, i) => [node, i])); 158 | const scan = []; 159 | const cross = []; 160 | for (const [i, node] of topLayer.entries()) { 161 | const ind = bottomInds.get(node); 162 | if (ind === undefined) { 163 | scan.push([i, node] as const); 164 | } else { 165 | cross.push([i, ind] as const); 166 | } 167 | } 168 | // go over all children and see if they cross 169 | for (const [i, node] of scan) { 170 | for (const [t, b] of cross) { 171 | const topOrd = i < t; 172 | for (const child of node.children()) { 173 | const ci = bottomInds.get(child)!; 174 | if (topOrd !== ci < b) { 175 | return true; 176 | } 177 | } 178 | } 179 | } 180 | return false; 181 | } 182 | 183 | export const nodeSep = (a: unknown, b: unknown) => (+!!a + +!!b) / 2; 184 | 185 | /** canonical order for test nodes */ 186 | export function canonical(grf: Graph): GraphNode[] { 187 | const arr = [...grf.nodes()]; 188 | arr.sort((a, b) => parseInt(a.data) - parseInt(b.data)); 189 | return arr; 190 | } 191 | -------------------------------------------------------------------------------- /src/sugiyama/twolayer/greedy.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { Twolayer } from "."; 3 | import { SugiNode } from "../sugify"; 4 | import { createLayers, getIndex } from "../test-utils"; 5 | import { crossings } from "../utils"; 6 | import { twolayerAgg } from "./agg"; 7 | import { twolayerGreedy } from "./greedy"; 8 | 9 | test("twolayerGreedy() works for very simple case", () => { 10 | // independent links that need to be swapped 11 | const [topLayer, bottomLayer] = createLayers([ 12 | [[1], [0]], 13 | [[], []], 14 | ]); 15 | const layout = twolayerGreedy(); 16 | expect(layout.scan()).toBe(false); 17 | layout(topLayer, bottomLayer, true); 18 | const inds = bottomLayer.map(getIndex); 19 | expect(inds).toEqual([1, 0]); 20 | }); 21 | 22 | test("twolayerGreedy() allows setting custom bases", () => { 23 | function base(above: SugiNode[], below: SugiNode[]): void { 24 | for (const _ of above) { 25 | // noop 26 | } 27 | for (const _ of below) { 28 | // noop 29 | } 30 | } 31 | 32 | const init = twolayerGreedy() satisfies Twolayer; 33 | 34 | const layout = init.base(base) satisfies Twolayer; 35 | // @ts-expect-error invalid data; 36 | layout satisfies Twolayer; 37 | 38 | expect(layout.base() satisfies typeof base).toBe(base); 39 | }); 40 | 41 | test("twolayerGreedy() works for very simple compact case", () => { 42 | // independent links that need to be swapped 43 | const [topLayer, bottomLayer] = createLayers([ 44 | [[1], [0], 2n, [3], [4]], 45 | [[], [], [], [], []], 46 | ]); 47 | const layout = twolayerGreedy(); 48 | expect(layout.scan()).toBe(false); 49 | layout(topLayer, bottomLayer, true); 50 | const inds = bottomLayer.map(getIndex); 51 | expect(inds).toEqual([1, 0, 2, 3, 4]); 52 | }); 53 | 54 | test("twolayerGreedy() works for very simple case bottom-up", () => { 55 | // independent links that need to be swapped 56 | const [topLayer, bottomLayer] = createLayers([ 57 | [[1], [0]], 58 | [[], []], 59 | ]); 60 | const layout = twolayerGreedy(); 61 | layout(topLayer, bottomLayer, false); 62 | const inds = topLayer.map(getIndex); 63 | expect(inds).toEqual([1, 0]); 64 | }); 65 | 66 | test("twolayerGreedy() improves suboptimal median", () => { 67 | const [topLayer, bottomLayer] = createLayers([ 68 | [[3], [2], [2], [0], [1], [3], [2]], 69 | [[], [], [], []], 70 | ]); 71 | const base = twolayerAgg(); 72 | const layout = twolayerGreedy().base(base); 73 | expect(layout.base()).toBe(base); 74 | layout(topLayer, bottomLayer, true); 75 | // NOTE is 8 before greedy, 6 is optimal 76 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(6); 77 | const inds = bottomLayer.map(getIndex); 78 | expect(inds).toEqual([3, 2, 0, 1]); 79 | }); 80 | 81 | test("twolayerGreedy() scan fails where opt succeeds", () => { 82 | const [topLayer, bottomLayer] = createLayers([ 83 | [[0, 3, 4], [1, 4], [2]], 84 | [[], [], [], [], []], 85 | ]); 86 | const layout = twolayerGreedy().scan(true); 87 | layout(topLayer, bottomLayer, false); 88 | // optimal is 4 89 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(5); 90 | const inds = topLayer.map(getIndex); 91 | expect(inds).toEqual([0, 1, 2]); // no greedy swaps possible 92 | }); 93 | 94 | test("twolayerGreedy() preserves order of easy unconstrained nodes", () => { 95 | const [topLayer, bottomLayer] = createLayers([ 96 | [[0], [2]], 97 | [[], [], []], 98 | ]); 99 | const layout = twolayerGreedy(); 100 | layout(topLayer, bottomLayer, true); 101 | const inds = bottomLayer.map(getIndex); 102 | expect(inds).toEqual([0, 1, 2]); 103 | }); 104 | 105 | test("twolayerGreedy() preserves order of easy unconstrained nodes bottom-up", () => { 106 | const [topLayer, bottomLayer] = createLayers([ 107 | [[0], [], [1]], 108 | [[], []], 109 | ]); 110 | const layout = twolayerGreedy(); 111 | layout(topLayer, bottomLayer, false); 112 | const inds = topLayer.map(getIndex); 113 | expect(inds).toEqual([0, 1, 2]); 114 | }); 115 | 116 | test("twolayerGreedy() fails with neutral adjacent", () => { 117 | const [topLayer, bottomLayer] = createLayers([ 118 | [[1], [], [0]], 119 | [[], []], 120 | ]); 121 | const layout = twolayerGreedy(); 122 | layout(topLayer, bottomLayer, false); 123 | const inds = topLayer.map(getIndex); 124 | expect(inds).toEqual([0, 1, 2]); 125 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(1); 126 | }); 127 | 128 | test("twolayerGreedy() scan works with neutral adjacent", () => { 129 | const [topLayer, bottomLayer] = createLayers([ 130 | [[1], [], [0]], 131 | [[], []], 132 | ]); 133 | const layout = twolayerGreedy().scan(true); 134 | expect(layout.scan()).toBe(true); 135 | layout(topLayer, bottomLayer, false); 136 | const inds = topLayer.map(getIndex); 137 | expect(inds).toEqual([2, 1, 0]); 138 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(0); 139 | }); 140 | 141 | test("twolayerGreedy() scan can swap a node twice", () => { 142 | const [topLayer, bottomLayer] = createLayers([ 143 | [[1], [2, 3], [0]], 144 | [[], [], [], []], 145 | ]); 146 | const layout = twolayerGreedy().scan(true); 147 | layout(topLayer, bottomLayer, false); 148 | const inds = topLayer.map(getIndex); 149 | expect(inds).toEqual([2, 0, 1]); 150 | }); 151 | 152 | test("twolayerGreedy() scan will interleave intervals", () => { 153 | const [topLayer, bottomLayer] = createLayers([ 154 | [[3, 4], [5], [0, 1], [2]], 155 | [[], [], [], [], [], []], 156 | ]); 157 | const layout = twolayerGreedy().scan(true); 158 | layout(topLayer, bottomLayer, false); 159 | const inds = topLayer.map(getIndex); 160 | expect(inds).toEqual([2, 3, 0, 1]); 161 | }); 162 | 163 | test("twolayerGreedy() scan improves suboptimal median", () => { 164 | const [topLayer, bottomLayer] = createLayers([ 165 | [[3], [2], [2], [0], [1], [3], [2]], 166 | [[], [], [], []], 167 | ]); 168 | const base = twolayerAgg(); 169 | const layout = twolayerGreedy().base(base).scan(true); 170 | expect(layout.base()).toBe(base); 171 | expect(layout.scan()).toBe(true); 172 | layout(topLayer, bottomLayer, true); 173 | // NOTE is 8 before greedy, 6 is optimal 174 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(6); 175 | const inds = bottomLayer.map(getIndex); 176 | expect(inds).toEqual([3, 2, 0, 1]); 177 | }); 178 | 179 | test("twolayerGreedy() fails passing an arg to constructor", () => { 180 | // @ts-expect-error no args 181 | expect(() => twolayerGreedy(null)).toThrow("got arguments to twolayerGreedy"); 182 | }); 183 | -------------------------------------------------------------------------------- /src/sugiyama/twolayer/greedy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * a {@link TwolayerGreedy} that calls another {@link Twolayer} before greedily 3 | * swapping nodes to minimize crossings. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { Twolayer } from "."; 8 | import { err } from "../../utils"; 9 | import { SugiNode } from "../sugify"; 10 | 11 | /** 12 | * a {@link Twolayer} that greedily swaps nodes 13 | * 14 | * Create with {@link twolayerGreedy}. 15 | */ 16 | export interface TwolayerGreedy 17 | extends Twolayer< 18 | Op extends Twolayer ? N : never, 19 | Op extends Twolayer ? L : never 20 | > { 21 | /** 22 | * set the base {@link Twolayer} for this operator 23 | * 24 | * Greedy will first call its base operator, and the greedily swap nodes to 25 | * minimize edge crossings. To only greedily minimize edge crossings, set 26 | * base to a no op. 27 | * 28 | * (default: noop) 29 | */ 30 | base(val: NewOp): TwolayerGreedy; 31 | /** 32 | * get the current base operator 33 | */ 34 | base(): Op; 35 | 36 | /** 37 | * set whether this operator should scan to find swaps. 38 | * 39 | * Using the scan method takes longer (quadratic in layer size, versus 40 | * linear), but produces fewer crossings. 41 | * 42 | * (default: `false`) 43 | */ 44 | scan(val: boolean): TwolayerGreedy; 45 | /** 46 | * get the current scan setting 47 | */ 48 | scan(): boolean; 49 | 50 | /** @internal flag indicating that this is built in to d3dag and shouldn't error in specific instances */ 51 | readonly d3dagBuiltin: true; 52 | } 53 | 54 | interface SwapChange { 55 | (left: SugiNode, right: SugiNode): number; 56 | } 57 | 58 | function createSwapChange( 59 | stationary: readonly SugiNode[], 60 | children: (node: SugiNode) => Iterable<[SugiNode, number]>, 61 | ): SwapChange { 62 | const cache = new Map>(); 63 | const inds = new Map(stationary.map((n, i) => [n, i])); 64 | 65 | function swapChange(left: SugiNode, right: SugiNode): number { 66 | const val = cache.get(left)?.get(right); 67 | if (val !== undefined) { 68 | return val; // cached 69 | } else if (inds.has(left) || inds.has(right)) { 70 | return -Infinity; // can't swap compact nodes 71 | } else { 72 | let delta = 0; 73 | for (const [cl, nl] of children(left)) { 74 | const il = inds.get(cl)!; 75 | for (const [cr, nr] of children(right)) { 76 | const ir = inds.get(cr)!; 77 | delta += Math.sign(il - ir) * nl * nr; 78 | } 79 | } 80 | 81 | const cacheLeft = cache.get(left); 82 | if (cacheLeft === undefined) { 83 | cache.set(left, new Map([[right, delta]])); 84 | } else { 85 | cacheLeft.set(right, delta); 86 | } 87 | 88 | const cacheRight = cache.get(right); 89 | if (cacheRight === undefined) { 90 | cache.set(right, new Map([[left, -delta]])); 91 | } else { 92 | cacheRight.set(left, -delta); 93 | } 94 | 95 | return delta; 96 | } 97 | } 98 | 99 | return swapChange; 100 | } 101 | 102 | function adjacentSwap( 103 | layer: SugiNode[], 104 | swapChange: SwapChange, 105 | ): void { 106 | const ranges: [number, number][] = [[0, layer.length]]; 107 | let range; 108 | while ((range = ranges.pop())) { 109 | const [start, end] = range; 110 | if (start >= end) continue; 111 | let max = 0; 112 | let ind = end; 113 | for (let i = start; i < end - 1; ++i) { 114 | const diff = swapChange(layer[i], layer[i + 1]); 115 | if (diff > max) { 116 | max = diff; 117 | ind = i; 118 | } 119 | } 120 | if (ind !== end) { 121 | const temp = layer[ind + 1]; 122 | layer[ind + 1] = layer[ind]; 123 | layer[ind] = temp; 124 | ranges.push([start, ind], [ind + 2, end]); 125 | } 126 | } 127 | } 128 | 129 | function scanSwap( 130 | layer: SugiNode[], 131 | swapChange: SwapChange, 132 | ): void { 133 | const costs: number[] = Array( 134 | (layer.length * (layer.length - 1)) / 2, 135 | ); 136 | for (;;) { 137 | let start = 0; 138 | for (let ti = 1; ti < layer.length; ++ti) { 139 | let cum = 0; 140 | let ind = start; 141 | for (let fi = ti - 1; fi >= 0; --fi) { 142 | costs[ind] = cum; 143 | cum += swapChange(layer[fi], layer[ti]); 144 | ind -= layer.length - fi - 1; 145 | } 146 | start += layer.length - ti; 147 | } 148 | 149 | let ind = 0; 150 | let max = 0; 151 | let fromInd = 0; 152 | let toInd = 0; 153 | for (let fi = 0; fi < layer.length - 1; ++fi) { 154 | let cum = 0; 155 | for (let ti = fi + 1; ti < layer.length; ++ti) { 156 | cum += swapChange(layer[fi], layer[ti]); 157 | const val = costs[ind++] + cum; 158 | if (val > max) { 159 | max = val; 160 | fromInd = fi; 161 | toInd = ti; 162 | } 163 | } 164 | } 165 | 166 | // no more swaps; 167 | if (max === 0) break; 168 | // else do swap and try again 169 | const temp = layer[toInd]; 170 | layer[toInd] = layer[fromInd]; 171 | layer[fromInd] = temp; 172 | } 173 | } 174 | 175 | function buildOperator>({ 176 | baseOp, 177 | doScan, 178 | }: { 179 | baseOp: Op & Twolayer; 180 | doScan: boolean; 181 | }): TwolayerGreedy { 182 | function twolayerGreedy( 183 | topLayer: SugiNode[], 184 | bottomLayer: SugiNode[], 185 | topDown: boolean, 186 | ): void { 187 | baseOp(topLayer, bottomLayer, topDown); 188 | 189 | const layer = topDown ? bottomLayer : topLayer; 190 | const swapChange = topDown 191 | ? createSwapChange(topLayer, (node) => node.parentCounts()) 192 | : createSwapChange(bottomLayer, (node) => node.childCounts()); 193 | 194 | if (doScan) { 195 | scanSwap(layer, swapChange); 196 | } else { 197 | adjacentSwap(layer, swapChange); 198 | } 199 | } 200 | 201 | function base(val: NewOp): TwolayerGreedy; 202 | function base(): Op; 203 | function base( 204 | val?: NewOp, 205 | ): Op | TwolayerGreedy { 206 | if (val === undefined) { 207 | return baseOp; 208 | } else { 209 | return buildOperator({ baseOp: val, doScan }); 210 | } 211 | } 212 | twolayerGreedy.base = base; 213 | 214 | function scan(val: boolean): TwolayerGreedy; 215 | function scan(): boolean; 216 | function scan(val?: boolean): boolean | TwolayerGreedy { 217 | if (val === undefined) { 218 | return doScan; 219 | } else { 220 | return buildOperator({ baseOp, doScan: val }); 221 | } 222 | } 223 | twolayerGreedy.scan = scan; 224 | 225 | twolayerGreedy.d3dagBuiltin = true as const; 226 | 227 | return twolayerGreedy; 228 | } 229 | 230 | /** default greedy operator */ 231 | export type DefaultTwolayerGreedy = TwolayerGreedy>; 232 | 233 | /** 234 | * create a default {@link TwolayerGreedy} 235 | * 236 | * This may be faster than {@link twolayerOpt}, but should produce better 237 | * layouts than {@link twolayerAgg} on its own. 238 | * 239 | * @example 240 | * 241 | * ```ts 242 | * const layout = sugiyama().decross(decrossTwoLayer().order(twolayerGreedy())); 243 | * ``` 244 | */ 245 | export function twolayerGreedy(...args: never[]): DefaultTwolayerGreedy { 246 | if (args.length) { 247 | throw err`got arguments to twolayerGreedy(${args}); you probably forgot to construct twolayerGreedy before passing to order: \`decrossTwoLayer().order(twolayerGreedy())\`, note the trailing "()"`; 248 | } 249 | return buildOperator({ baseOp: () => undefined, doScan: false }); 250 | } 251 | -------------------------------------------------------------------------------- /src/sugiyama/twolayer/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { compactCrossings, createLayers, getIndex } from "../test-utils"; 3 | import { aggMean, aggMedian, aggWeightedMedian, twolayerAgg } from "./agg"; 4 | import { twolayerGreedy } from "./greedy"; 5 | import { twolayerOpt } from "./opt"; 6 | 7 | const square = () => createLayers([[[0, 1]], [[], []]]); 8 | const ccoz = () => 9 | createLayers([ 10 | [[1], [0, 3], [2]], 11 | [[], [], [], []], 12 | ]); 13 | const doub = () => 14 | createLayers([ 15 | [[1], [0]], 16 | [[], []], 17 | ]); 18 | const compact = () => 19 | createLayers([ 20 | [[1], [0], 2n, [3], [4]], 21 | [[], [], [], [], []], 22 | ]); 23 | 24 | for (const dat of [square, ccoz, doub, compact]) { 25 | for (const [name, method] of [ 26 | ["mean", twolayerAgg().aggregator(aggMean)], 27 | ["median", twolayerAgg().aggregator(aggMedian)], 28 | ["weighted median", twolayerAgg().aggregator(aggWeightedMedian)], 29 | ["swap", twolayerGreedy().scan(false)], 30 | ["scan", twolayerGreedy().scan(true)], 31 | ["opt", twolayerOpt()], 32 | ] as const) { 33 | test(`invariants apply to ${dat.name} decrossed by ${name}`, () => { 34 | const [topLayer, bottomLayer] = dat(); 35 | 36 | // applying two layer again does not produce a new order 37 | const bottomMut = bottomLayer.slice(); 38 | method(topLayer, bottomMut, true); 39 | const afterBottom = bottomMut.map(getIndex); 40 | method(topLayer, bottomMut, true); 41 | const dupBottom = bottomMut.map(getIndex); 42 | expect(dupBottom).toEqual(afterBottom); 43 | expect(compactCrossings(topLayer, bottomMut)).toBeFalsy(); 44 | 45 | // applying two layer again does not produce a new order bottom-up 46 | const topMut = topLayer.slice(); 47 | method(topMut, bottomLayer, false); 48 | const afterTop = topMut.map(getIndex); 49 | method(topMut, bottomLayer, false); 50 | const dupTop = topMut.map(getIndex); 51 | expect(dupTop).toEqual(afterTop); 52 | expect(compactCrossings(topMut, bottomLayer)).toBeFalsy(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/sugiyama/twolayer/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The definition of the {@link Twolayer} interface, which is used to 3 | * customize {@link DecrossTwoLayer}. 4 | * 5 | * @packageDocumentation 6 | */ 7 | import { SugiNode } from "../sugify"; 8 | 9 | /** 10 | * an operator for optimizing decrossings one layer at a time. 11 | * 12 | * This is used to customize {@link DecrossTwoLayer}. 13 | * 14 | * When called with `topDown = true` `topLayer` should be untouched, and 15 | * `bottomLayer` should be rearranged to minimize crossings. When 16 | * `topDown = false` then `topLayer` should be rearranged, and `bottomLayer` 17 | * should remain fixed. There are no requirements for how these needs to order 18 | * nodes, but doing so in such a way to minimize edge crossings is usually 19 | * desired. 20 | * 21 | * @example 22 | * 23 | * It's unlikely that you'll need to implement a custom two-layer operator as 24 | * this is already a heuristic solution to decrossing. However, in the event 25 | * that you do, we illustrate how to implement one where the order is stored in 26 | * the original nodes. Here dummy nodes (nodes that exist on long edges between 27 | * "real" nodes) are ordered according to the average value of their source and 28 | * target nodes. 29 | * 30 | * ```ts 31 | * function myTwoLayer(topLayer: SugiNode<{ ord: number }, unknown>[], bottomLayer: SugiNode<{ ord: number }, unknown>[], topDown: boolean): void { 32 | * let mutate = topDown ? bottomLayer : topLayer; 33 | * const vals = new Map(); 34 | * for (const node of mutate) { 35 | * const { data } = node; 36 | * const val = data.role === "node" ? data.node.data.ord ? (data.link.source.data.ord + data.link.target.data.ord) / 2; 37 | * vals.set(node, val); 38 | * } 39 | * layer.sort((a, b) => vals.get(a)! - vals.get(b)!); 40 | * } 41 | * ``` 42 | */ 43 | export interface Twolayer { 44 | /** 45 | * rearrange one layer conditioned on another 46 | * 47 | * @param topLayer - the top layer 48 | * @param bottomLayer - the bottom layer 49 | * @param topDown - if true rearrange `bottomLayer`, else rearrange `topLayer` 50 | */ 51 | ( 52 | topLayer: SugiNode[], 53 | bottomLayer: SugiNode[], 54 | topDown: boolean, 55 | ): void; 56 | } 57 | -------------------------------------------------------------------------------- /src/sugiyama/twolayer/opt.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { createLayers, getIndex } from "../test-utils"; 3 | import { crossings } from "../utils"; 4 | import { twolayerOpt } from "./opt"; 5 | 6 | test("twolayerOpt() allows setting options", () => { 7 | const layering = twolayerOpt().check("oom").dist(true); 8 | expect(layering.check()).toEqual("oom"); 9 | expect(layering.dist()).toBeTruthy(); 10 | }); 11 | 12 | test("twolayerOpt() works for very simple case", () => { 13 | // independent links that need to be swapped 14 | const [topLayer, bottomLayer] = createLayers([ 15 | [[1], [0]], 16 | [[], []], 17 | ]); 18 | twolayerOpt()(topLayer, bottomLayer, true); 19 | const inds = bottomLayer.map(getIndex); 20 | expect(inds).toEqual([1, 0]); 21 | }); 22 | 23 | test("twolayerOpt() works for very simple compact case", () => { 24 | // independent links that need to be swapped 25 | const [topLayer, bottomLayer] = createLayers([ 26 | [[1], [0], 2n, [3], [4]], 27 | [[], [], [], [], []], 28 | ]); 29 | twolayerOpt()(topLayer, bottomLayer, true); 30 | const inds = bottomLayer.map(getIndex); 31 | expect(inds).toEqual([1, 0, 2, 3, 4]); 32 | }); 33 | 34 | test("twolayerOpt() works for very simple case bottom-up", () => { 35 | // independent links that need to be swapped 36 | const [topLayer, bottomLayer] = createLayers([ 37 | [[1], [0]], 38 | [[], []], 39 | ]); 40 | twolayerOpt()(topLayer, bottomLayer, false); 41 | const inds = topLayer.map(getIndex); 42 | expect(inds).toEqual([1, 0]); 43 | }); 44 | 45 | test("twolayerOpt() works where mean fails", () => { 46 | const [topLayer, bottomLayer] = createLayers([ 47 | [[4], [4], [0], [1], [2], [3], [4]], 48 | [[], [], [], [], []], 49 | ]); 50 | bottomLayer.reverse(); 51 | twolayerOpt()(topLayer, bottomLayer, true); 52 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(4); 53 | const inds = bottomLayer.map(getIndex); 54 | expect(inds).toEqual([4, 0, 1, 2, 3]); 55 | }); 56 | 57 | test("twolayerOpt() works where median is suboptimal", () => { 58 | const [topLayer, bottomLayer] = createLayers([ 59 | [[3], [2], [2], [0], [1], [3], [2]], 60 | [[], [], [], []], 61 | ]); 62 | twolayerOpt()(topLayer, bottomLayer, true); 63 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(6); 64 | const inds = bottomLayer.map(getIndex); 65 | expect(inds).toEqual([3, 2, 0, 1]); 66 | }); 67 | 68 | test("twolayerOpt() works where median is suboptimal bottom-up", () => { 69 | const [topLayer, bottomLayer] = createLayers([ 70 | [[3], [4], [1, 2, 6], [0, 5]], 71 | [[], [], [], [], [], [], []], 72 | ]); 73 | twolayerOpt()(topLayer, bottomLayer, false); 74 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(6); 75 | const inds = topLayer.map(getIndex); 76 | expect(inds).toEqual([3, 2, 0, 1]); 77 | }); 78 | 79 | test("twolayerOpt() works where greedy scan is suboptimal", () => { 80 | const [topLayer, bottomLayer] = createLayers([ 81 | [[0, 3, 4], [1, 4], [2]], 82 | [[], [], [], [], []], 83 | ]); 84 | const layout = twolayerOpt(); 85 | layout(topLayer, bottomLayer, false); 86 | expect(crossings([topLayer, bottomLayer])).toBeCloseTo(4); 87 | const inds = topLayer.map(getIndex); 88 | expect(inds).toEqual([2, 0, 1]); 89 | }); 90 | 91 | test("twolayerOpt() preserves order of easy unconstrained nodes", () => { 92 | const [topLayer, bottomLayer] = createLayers([ 93 | [[0], [2]], 94 | [[], [], []], 95 | ]); 96 | twolayerOpt()(topLayer, bottomLayer, true); 97 | const inds = bottomLayer.map(getIndex); 98 | expect(inds).toEqual([0, 1, 2]); 99 | }); 100 | 101 | test("twolayerOpt() preserves order of easy unconstrained nodes bottom-up", () => { 102 | const [topLayer, bottomLayer] = createLayers([ 103 | [[0], [], [1]], 104 | [[], []], 105 | ]); 106 | twolayerOpt()(topLayer, bottomLayer, false); 107 | const inds = topLayer.map(getIndex); 108 | expect(inds).toEqual([0, 1, 2]); 109 | }); 110 | 111 | test("twolayerOpt() preserves order of multiple easy unconstrained nodes", () => { 112 | const [topLayer, bottomLayer] = createLayers([ 113 | [[0], [3]], 114 | [[], [], [], []], 115 | ]); 116 | twolayerOpt()(topLayer, bottomLayer, true); 117 | const inds = bottomLayer.map(getIndex); 118 | expect(inds).toEqual([0, 1, 2, 3]); 119 | }); 120 | 121 | test("twolayerOpt() preserves order of unconstrained nodes to front", () => { 122 | const [topLayer, bottomLayer] = createLayers([ 123 | [[3], [2], [0]], 124 | [[], [], [], []], 125 | ]); 126 | twolayerOpt()(topLayer, bottomLayer, true); 127 | const inds = bottomLayer.map(getIndex); 128 | expect(inds).toEqual([1, 3, 2, 0]); 129 | }); 130 | 131 | test("twolayerOpt() preserves order of unconstrained nodes to back", () => { 132 | const [topLayer, bottomLayer] = createLayers([ 133 | [[3], [1], [0]], 134 | [[], [], [], []], 135 | ]); 136 | twolayerOpt()(topLayer, bottomLayer, true); 137 | const inds = bottomLayer.map(getIndex); 138 | expect(inds).toEqual([3, 1, 0, 2]); 139 | }); 140 | 141 | test("twolayerOpt() doesn't optimize for distance", () => { 142 | const [topLayer, bottomLayer] = createLayers([[[0, 2]], [[], [], []]]); 143 | twolayerOpt()(topLayer, bottomLayer, true); 144 | const inds = bottomLayer.map(getIndex); 145 | expect(inds).toEqual([0, 1, 2]); 146 | }); 147 | 148 | test("twolayerOpt() can optimize for distance", () => { 149 | const [topLayer, bottomLayer] = createLayers([[[0, 2]], [[], [], []]]); 150 | twolayerOpt().dist(true)(topLayer, bottomLayer, true); 151 | const inds = bottomLayer.map(getIndex); 152 | // NOTE brittle in that 1 can move to either side 153 | expect(inds).toEqual([1, 0, 2]); 154 | }); 155 | 156 | test("twolayerOpt() no ops on distance", () => { 157 | const [topLayer, bottomLayer] = createLayers([[[0, 1]], [[], [], []]]); 158 | const twolayer = twolayerOpt().dist(true); 159 | twolayer(topLayer, bottomLayer, true); 160 | const inds = bottomLayer.map(getIndex); 161 | expect(inds).toEqual([0, 1, 2]); 162 | }); 163 | 164 | test("twolayerOpt() can optimize for distance bottom-up", () => { 165 | const [topLayer, bottomLayer] = createLayers([[[0], [], [0]], [[]]]); 166 | twolayerOpt().dist(true)(topLayer, bottomLayer, false); 167 | const inds = topLayer.map(getIndex); 168 | // NOTE brittle in that 1 can move to either side 169 | expect(inds).toEqual([1, 0, 2]); 170 | }); 171 | 172 | test("twolayerOpt() fails for large inputs", () => { 173 | const [topLayer, bottomLayer] = createLayers([ 174 | Array(51) 175 | .fill(null) 176 | .map((_, i) => [i]), 177 | Array(51).fill([]), 178 | ]); 179 | expect(() => twolayerOpt()(topLayer, bottomLayer, true)).toThrow(`"oom"`); 180 | }); 181 | 182 | test("twolayerOpt() fails for medium inputs", () => { 183 | const [topLayer, bottomLayer] = createLayers([ 184 | Array(31) 185 | .fill(null) 186 | .map((_, i) => [i]), 187 | Array(31).fill([]), 188 | ]); 189 | expect(() => twolayerOpt()(topLayer, bottomLayer, true)).toThrow(`"slow"`); 190 | }); 191 | 192 | test("twolayerOpt() fails passing an arg to constructor", () => { 193 | // @ts-expect-error no args 194 | expect(() => twolayerOpt(null)).toThrow("got arguments to twolayerOpt"); 195 | }); 196 | -------------------------------------------------------------------------------- /src/sugiyama/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { compactCrossings, createLayers } from "./test-utils"; 3 | 4 | test("compactCrossings() detects crossing", () => { 5 | const [topLayer, bottomLayer] = createLayers([ 6 | [[1], 0n], 7 | [[], []], 8 | ]); 9 | expect(compactCrossings(topLayer, bottomLayer)).toBeTruthy(); 10 | }); 11 | -------------------------------------------------------------------------------- /src/sugiyama/utils.ts: -------------------------------------------------------------------------------- 1 | import { GraphNode } from "../graph"; 2 | import { bigrams } from "../iters"; 3 | import { NodeLength } from "../layout"; 4 | 5 | /** 6 | * A separation function that indicates how far apart nodes should be the layering / height assignment. 7 | * 8 | * 9 | * @remarks upper and lower are historic, since arbitrary graphs are handled, 10 | * there is no longer a notion of upper or lower and separation should return 11 | * the correct separation independent of nodes relations in the graph. 12 | */ 13 | export interface Separation { 14 | /** 15 | * compute the necessary separation between two nodes 16 | * 17 | * `first` and `second` are `undefined` to indicate the separation from the 18 | * extents of the layout (0, or height). Both will never be `undefined`. 19 | * 20 | * @param first - one node to find the separation between 21 | * @param second - the other node to find the separation between 22 | * @returns sep - the minimum separation between the two nodes, regardless of which 23 | * one is on top 24 | */ 25 | ( 26 | first: GraphNode | undefined, 27 | second: GraphNode | undefined, 28 | ): number; 29 | } 30 | 31 | /** 32 | * A separation derived from a length and a gap 33 | * 34 | * This is the separation function if each node has size `len` and between two 35 | * nodes there's an extra `gap`. 36 | */ 37 | export function sizedSeparation( 38 | len: NodeLength, 39 | gap: number, 40 | ): Separation { 41 | function sizedSeparation( 42 | left: GraphNode | undefined, 43 | right: GraphNode | undefined, 44 | ): number { 45 | const llen = left ? len(left) : 0; 46 | const rlen = right ? len(right) : 0; 47 | const base = (llen + rlen) / 2; 48 | return left && right ? base + gap : base; 49 | } 50 | return sizedSeparation; 51 | } 52 | 53 | /** compute the number of crossings in a layered sugi node */ 54 | export function crossings(layers: readonly (readonly GraphNode[])[]): number { 55 | let crossings = 0; 56 | for (const [topLayer, bottomLayer] of bigrams(layers)) { 57 | const inds = new Map(bottomLayer.map((node, j) => [node, j] as const)); 58 | for (const [j, p1] of topLayer.entries()) { 59 | for (const p2 of topLayer.slice(j + 1)) { 60 | for (const [c1, n1] of p1.childCounts()) { 61 | for (const [c2, n2] of p2.childCounts()) { 62 | if (c1 !== c2 && inds.get(c1)! > inds.get(c2)!) { 63 | crossings += n1 * n2; 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | return crossings; 71 | } 72 | -------------------------------------------------------------------------------- /src/test-graphs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * example graphs for testing 3 | * 4 | * @internal 5 | * @packageDocumentation 6 | */ 7 | import { Graph } from "./graph"; 8 | import { graphConnect } from "./graph/connect"; 9 | 10 | export type ConnectGraph = Graph; 11 | 12 | const connect = graphConnect().single(true); 13 | 14 | // single node 15 | // 0 16 | export function single(): ConnectGraph { 17 | return connect([["0", "0"]]); 18 | } 19 | 20 | // two independent nodes 21 | // 0 1 22 | export function doub(): ConnectGraph { 23 | return connect([ 24 | ["0", "0"], 25 | ["1", "1"], 26 | ]); 27 | } 28 | 29 | // two connected nodes 30 | // 0 31 | // | 32 | // 1 33 | export function line(): ConnectGraph { 34 | return connect([["0", "1"]]); 35 | } 36 | 37 | // two nodes in a cycle 38 | // 0 39 | // ^\ 40 | // |/ 41 | // 1 42 | export function oh(): ConnectGraph { 43 | return connect([ 44 | ["0", "1"], 45 | ["1", "0"], 46 | ]); 47 | } 48 | 49 | // three independent nodes 50 | // 0 1 2 51 | export function trip(): ConnectGraph { 52 | return connect([ 53 | ["0", "0"], 54 | ["1", "1"], 55 | ["2", "2"], 56 | ]); 57 | } 58 | 59 | // simplest multi-graph 60 | // 0 61 | // / \ 62 | // \ / 63 | // 1 64 | export function multi(): ConnectGraph { 65 | return connect([ 66 | ["0", "1"], 67 | ["0", "1"], 68 | ]); 69 | } 70 | 71 | // simplest cycle 72 | // 0 73 | // / \ 74 | // \ / 75 | // 1 76 | export function cyc(): ConnectGraph { 77 | return connect([ 78 | ["0", "1"], 79 | ["1", "0"], 80 | ]); 81 | } 82 | 83 | // multi-graph where no skips should happen 84 | // 0 85 | // /|\ 86 | // | 1 | 87 | // \|/ 88 | // 2 89 | export function eye(): ConnectGraph { 90 | return connect([ 91 | ["0", "1"], 92 | ["0", "2"], 93 | ["0", "2"], 94 | ["1", "2"], 95 | ]); 96 | } 97 | 98 | // square, simple with a cycle 99 | // 0 100 | // / \ 101 | // 1 2 102 | // \ / 103 | // 3 104 | export function square(): ConnectGraph { 105 | return connect([ 106 | ["0", "1"], 107 | ["0", "2"], 108 | ["1", "3"], 109 | ["2", "3"], 110 | ]); 111 | } 112 | 113 | // minimal graph to require a dummy node 114 | // 0 115 | // |\ 116 | // | 1 117 | // |/ 118 | // 2 119 | export function dummy(): ConnectGraph { 120 | return connect([ 121 | ["0", "1"], 122 | ["0", "2"], 123 | ["1", "2"], 124 | ]); 125 | } 126 | 127 | // minimal graph with at least three in a row both ways 128 | // 0 129 | // /|\ 130 | // 1 2 3 131 | // \|/ 132 | // 4 133 | export function three(): ConnectGraph { 134 | return connect([ 135 | ["0", "1"], 136 | ["0", "2"], 137 | ["0", "3"], 138 | ["1", "4"], 139 | ["2", "4"], 140 | ["3", "4"], 141 | ]); 142 | } 143 | 144 | // simple two source graph shaped like an N 145 | // 0 1 146 | // |\| 147 | // 2 3 148 | export function en(): ConnectGraph { 149 | return connect([ 150 | ["0", "2"], 151 | ["0", "3"], 152 | ["1", "3"], 153 | ]); 154 | } 155 | 156 | // simple graph shaped like an x, requires offset nodes 157 | // 0 158 | // | 159 | // 1 2 160 | // \ / 161 | // 3 162 | // / \ 163 | // 4 5 164 | // | 165 | // 6 166 | export function ex(): ConnectGraph { 167 | return connect([ 168 | ["0", "1"], 169 | ["1", "3"], 170 | ["2", "3"], 171 | ["3", "4"], 172 | ["3", "5"], 173 | ["5", "6"], 174 | ]); 175 | } 176 | 177 | // graph from issue #43 178 | // 0 1 179 | // |\ | 180 | // 3 4 2 181 | // |/ 182 | // 7 5 183 | // | 184 | // 6 185 | export function ccoz(): ConnectGraph { 186 | return connect([ 187 | ["0", "3"], 188 | ["0", "4"], 189 | ["1", "2"], 190 | ["3", "7"], 191 | ["5", "6"], 192 | ["4", "7"], 193 | ]); 194 | } 195 | 196 | // zherebko example 197 | export function zhere(): ConnectGraph { 198 | return connect([ 199 | ["0", "1"], 200 | ["0", "4"], 201 | ["0", "6"], 202 | ["1", "2"], 203 | ["1", "3"], 204 | ["1", "4"], 205 | ["1", "6"], 206 | ["1", "7"], 207 | ["2", "5"], 208 | ["2", "7"], 209 | ["3", "6"], 210 | ["4", "6"], 211 | ["4", "7"], 212 | ["4", "8"], 213 | ["5", "7"], 214 | ["6", "7"], 215 | ["8", "9"], 216 | ["8", "10"], 217 | ]); 218 | } 219 | -------------------------------------------------------------------------------- /src/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { err } from "./utils"; 2 | 3 | /** assert something */ 4 | export function assert(statement: unknown): asserts statement { 5 | if (!statement) { 6 | throw err`failed assert`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // TODO when types are published as part of package, use those instead 2 | declare module "javascript-lp-solver" { 3 | /** a constraint definition */ 4 | export interface Constraint { 5 | /** the minimum value of the constraint */ 6 | min?: number; 7 | /** the max value of the constraint */ 8 | max?: number; 9 | } 10 | 11 | /** 12 | * a variable definition 13 | * 14 | * A mapping of all the constraints or objective values that it contributes 15 | * to. 16 | */ 17 | export type Variable = Record; 18 | 19 | /** a description of an lp */ 20 | export interface Model { 21 | /** the variable to optimize */ 22 | optimize: string; 23 | /** the optimize type */ 24 | opType: "max" | "min"; 25 | /** all constraints */ 26 | constraints: Record; 27 | /** all variables */ 28 | variables: Record; 29 | /** integer variables */ 30 | ints: Record; 31 | } 32 | 33 | /** special keys of the result */ 34 | export interface BaseResult { 35 | /** if solution was feasible */ 36 | feasible: boolean; 37 | /** the optimal value */ 38 | result: number; 39 | /** if the solution was bounded */ 40 | bounded: boolean; 41 | /** if the solution was integral */ 42 | isIntegral?: boolean; 43 | } 44 | 45 | /** the result of an optimization */ 46 | export type Result = BaseResult & Record; 47 | 48 | /** function to solve an LP */ 49 | export function Solve(args: Model): Result; 50 | } 51 | 52 | declare module "quadprog" { 53 | /** the result of an optimization */ 54 | export interface Result { 55 | /** the solution */ 56 | solution: number[]; 57 | /** the lagrangian of the solution */ 58 | Lagrangian: number[]; 59 | /** the optimal value of the solution */ 60 | value: [unknown, number]; 61 | /** the solution without constraints */ 62 | unconstrained_solution: number[]; 63 | /** the number of iterations */ 64 | iterations: [unknown, number, number]; 65 | /** the active constraints */ 66 | iact: number[]; 67 | /** an error message */ 68 | message: string; 69 | } 70 | 71 | /** function to solve a QP */ 72 | export function solveQP( 73 | Dmat: number[][], 74 | dvec: number[], 75 | Amat: number[][], 76 | bvec: number[], 77 | meq?: number, 78 | factorized?: [number, number], 79 | ): Result; 80 | } 81 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { setEqual } from "./collections"; 3 | import { assert } from "./test-utils"; 4 | import { berr, ierr } from "./utils"; 5 | 6 | test("assert throws", () => { 7 | expect(assert(true)).toBeUndefined(); 8 | expect(() => assert(false)).toThrow("failed assert"); 9 | }); 10 | 11 | test("setEquals fails for different sizes", () => { 12 | expect(setEqual(new Set(), new Set([1]))).toBe(false); 13 | }); 14 | 15 | test("i err()", () => { 16 | expect(ierr`prefix ${5} suffix`).toEqual( 17 | Error( 18 | "internal error: prefix 5 suffix; if you encounter this please submit an issue at: https://github.com/erikbrinkman/d3-dag/issues", 19 | ), 20 | ); 21 | }); 22 | 23 | function foo() { 24 | // noop 25 | } 26 | 27 | test("b err()", () => { 28 | expect(berr`type ${foo} extra info ${5}`).toEqual( 29 | Error("custom type 'foo' extra info 5"), 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * General utilities for use throughout the package 3 | * 4 | * @packageDocumentation 5 | */ 6 | import stringify from "stringify-object"; 7 | import { entries, map } from "./iters"; 8 | 9 | /** utility type for replacing keys with new value */ 10 | export type U = Omit & Record; 11 | 12 | /** a callback for things with children */ 13 | export interface ChildrenCallback { 14 | (node: T): Iterable; 15 | } 16 | 17 | /** depth first search for arbitrary types */ 18 | export function* dfs( 19 | children: ChildrenCallback, 20 | ...queue: T[] 21 | ): IterableIterator { 22 | const seen = new Set(); 23 | let node; 24 | while ((node = queue.pop()) !== undefined) { 25 | if (seen.has(node)) continue; 26 | yield node; 27 | seen.add(node); 28 | queue.push(...children(node)); 29 | } 30 | } 31 | 32 | /** 33 | * Interleave a larger array with a smaller iterable 34 | * 35 | * common part of template formatting 36 | */ 37 | function interleave( 38 | larger: readonly string[], 39 | smaller: Iterable, 40 | ): string { 41 | const formatted = []; 42 | for (const [i, val] of entries(smaller)) { 43 | formatted.push(larger[i]); 44 | formatted.push(val); 45 | } 46 | formatted.push(larger[larger.length - 1]); 47 | return formatted.join(""); 48 | } 49 | 50 | /** pretty error creation */ 51 | export function err(strings: readonly string[], ...objs: unknown[]): Error { 52 | const stringified = map(objs, (val) => 53 | stringify(val, { 54 | indent: " ", 55 | singleQuotes: false, 56 | inlineCharacterLimit: 60, 57 | }), 58 | ); 59 | return new Error(interleave(strings, stringified)); 60 | } 61 | 62 | /** internal error template */ 63 | function wrapInternalMsg(msg: string): string { 64 | return `internal error: ${msg}; if you encounter this please submit an issue at: https://github.com/erikbrinkman/d3-dag/issues`; 65 | } 66 | 67 | /** generic internal error */ 68 | export function ierr( 69 | strings: readonly string[], 70 | ...info: (string | number | bigint | boolean)[] 71 | ): Error { 72 | const stringified = map(info, (val) => val.toString()); 73 | return new Error(wrapInternalMsg(interleave(strings, stringified))); 74 | } 75 | 76 | /** something with a name, e.g. a function */ 77 | export interface Named { 78 | /** the function name */ 79 | name: string; 80 | /** an optional tag we use to identify builtin methods */ 81 | d3dagBuiltin?: true; 82 | } 83 | 84 | /** customized error when we detect call back was internal */ 85 | export function berr( 86 | strings: readonly string[], 87 | named: Named, 88 | ...info: (string | number | bigint)[] 89 | ): Error { 90 | const [typ, ...rest] = strings; 91 | const stringified = map(info, (val) => val.toString()); 92 | const msg = interleave(rest, stringified); 93 | const name = named.name || "anonymous"; 94 | /* istanbul ignore next */ 95 | return new Error( 96 | "d3dagBuiltin" in named 97 | ? wrapInternalMsg(`builtin ${typ}'${name}'${msg}`) 98 | : `custom ${typ}'${name}'${msg}`, 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/zherebko/greedy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Until this is customizable, this is an internal module 3 | * 4 | * @internal 5 | * @packageDocumentation 6 | */ 7 | // TODO turn this into an operator for zherebko 8 | import { GraphLink, GraphNode } from "../graph"; 9 | import { bigrams } from "../iters"; 10 | 11 | function* zhereParentLinks( 12 | node: GraphNode, 13 | ): IterableIterator<[GraphNode, GraphNode, GraphLink]> { 14 | for (const link of node.parentLinks()) { 15 | const { source } = link; 16 | if (source.y < node.y) { 17 | yield [source, node, link]; 18 | } 19 | } 20 | for (const link of node.childLinks()) { 21 | const { target } = link; 22 | if (target.y < node.y) { 23 | yield [target, node, link]; 24 | } 25 | } 26 | } 27 | 28 | function firstAvailable(inds: number[], target: number) { 29 | const index = inds.findIndex((i) => i < target); 30 | if (index >= 0) { 31 | return index; 32 | } else { 33 | return inds.length; 34 | } 35 | } 36 | 37 | /** 38 | * @returns link map which takes source, target, and ind to get lane 39 | */ 40 | export function greedy( 41 | nodes: readonly GraphNode[], 42 | gap: number, 43 | ): Map { 44 | // We first create an array of every link we want to render, with the layer 45 | // of their source and target node so that we can sort first by target layer 46 | // then by source layer to greedily assign lanes so they don't intersect 47 | const links = []; 48 | for (const [last, node] of bigrams(nodes)) { 49 | let seen = false; 50 | for (const info of zhereParentLinks(node)) { 51 | const [source] = info; 52 | if (last === source && !seen) { 53 | seen = true; 54 | } else { 55 | links.push(info); 56 | } 57 | } 58 | } 59 | 60 | links.sort(([fs, ft], [ss, st]) => ft.y - st.y || ss.y - fs.y); 61 | 62 | const pos: number[] = []; 63 | const neg: number[] = []; 64 | 65 | const indices = new Map(); 66 | for (const [source, target, link] of links) { 67 | // we can technically put the gap both in the query and the save, but this 68 | // allows a little floating point tolerance. 69 | const negIndex = firstAvailable(neg, source.y); 70 | const posIndex = firstAvailable(pos, source.y); 71 | if (negIndex < posIndex) { 72 | // TODO tiebreak with crossing for insertion 73 | // NOTE there may not be a better layout than this, so this may not need 74 | // to be an operator if we can solve this aspect. 75 | // tie-break right 76 | indices.set(link, -negIndex - 1); 77 | neg[negIndex] = target.y - gap; 78 | } else { 79 | indices.set(link, posIndex + 1); 80 | pos[posIndex] = target.y - gap; 81 | } 82 | } 83 | return indices; 84 | } 85 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es6", 5 | "lib": ["es6"], 6 | "module": "es6", 7 | "noEmit": false, 8 | "declaration": true, 9 | "emitDeclarationOnly": true, 10 | "outDir": "./dist" 11 | }, 12 | "exclude": ["**/*.test.ts", "**/test-*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "isolatedModules": true, 10 | "noEmit": true, 11 | "strict": true, 12 | "incremental": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src"], 3 | "out": "docs", 4 | "excludeInternal": true, 5 | "cleanOutputDir": true, 6 | "validation": true, 7 | "githubPages": true, 8 | "treatWarningsAsErrors": true 9 | } 10 | --------------------------------------------------------------------------------