├── .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 | [](https://www.npmjs.com/package/d3-dag)
4 | [](https://github.com/erikbrinkman/d3-dag/actions)
5 | [](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