Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: ci

on: [push, pull_request]

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: go test ./...
- run: go test -race ./...
- run: go vet ./...
22 changes: 22 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2012-2014 Chris Pettitt
Copyright (c) 2026 Dagro contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
20 changes: 20 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Dagro is a Go port of Dagre 0.8.5 and the subset of Graphlib 2.1.8 required by
Dagre's layout implementation.

Compatibility sources:

Dagre 0.8.5
https://github.com/dagrejs/dagre
commit f56edb1abbb8530e532158f7cbd403228f5b0018

Graphlib 2.1.8
https://github.com/dagrejs/graphlib
commit 64375bb8d96bce0d906d238853c2b5afa2f2c231

Both upstream projects are licensed under the MIT License and carry the
copyright notice "Copyright (c) 2012-2014 Chris Pettitt". The repository's
LICENSE preserves that notice and license text.

Dagre 0.8.5 also depends on Lodash 4.17.15. Dagro does not include Lodash
source; Go helpers reproduce only the observable collection and numeric
semantics required by the port.
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Dagro

Dagro is a native Go port of [Dagre](https://github.com/dagrejs/dagre), the
directed graph layout engine. It intentionally replicates Dagre **0.8.5** and
the Graphlib **2.1.8** behavior used by that release, including layout order,
compound graphs, named multiedges, self-loops, and edge-label routing.

Dagro targets the exact Dagre 0.8.5 behavior embedded by D2. Later Dagre
releases retain the same broad layout pipeline but include behavior-changing
ordering, positioning, compound-layout, API, and dependency changes, so
compatibility is measured against 0.8.5.

## Usage

```go
package main

import (
"fmt"

"github.com/d2lang/dagro"
)

func main() {
g := dagro.NewGraph(dagro.GraphOptions{
Compound: true,
Multigraph: true,
}).SetGraph(dagro.Attrs{"rankdir": "TB"})

g.SetDefaultEdgeLabel(func(string, string, *string) any {
return dagro.Attrs{}
})
g.SetNode("a", dagro.Attrs{"width": 80, "height": 40})
g.SetNode("b", dagro.Attrs{"width": 80, "height": 40})
g.SetEdge("a", "b", dagro.Attrs{}, "a-to-b")

if err := dagro.Layout(g); err != nil {
panic(err)
}

a := g.Node("a").(dagro.Attrs)
fmt.Println(a["x"], a["y"])
}
```

The API uses `Attrs` maps because Dagre and Graphlib labels are open JavaScript
objects. Recognized numeric layout attributes accept Go numeric types and are
coerced to `float64` in the internal layout graph for JavaScript `Number`
compatibility; arbitrary label values are preserved as supplied.

## Compatibility and tests

The Go source follows the Dagre 0.8.5 module boundaries:

- cycle removal and greedy feedback-arc selection;
- compound nesting, normalization, and dummy-chain parenting;
- longest-path, tight-tree, and network-simplex ranking;
- weighted crossing minimization;
- Brandes-Köpf coordinate assignment;
- self-edge, label, border, direction, and translation passes.

The normal suite contains direct Go ports of the upstream tests. An optional
differential suite replays ordered fixtures through both implementations and
compares topology, point order, attribute presence, and all numeric output:

```sh
DAGRO_DAGRE_JS=/absolute/path/to/dagre-0.8.5.js go test ./...
```

The differential test uses `node` only as a test oracle. Dagro itself has no
JavaScript runtime or third-party Go dependencies.

## Versioning

`Version` reports the replicated Dagre version (`0.8.5`). Until the first
tagged release, consumers developing Dagro and D2 together can use a Go
workspace or a temporary local `replace` directive.

## License

MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE) for upstream attribution and
the exact compatibility revisions.
64 changes: 64 additions & 0 deletions acyclic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package dagro

type edgeNameState struct {
name string
present bool
}

func runAcyclic(g *Graph) {
var fas []Edge
if stringValue(asAttrs(g.Graph()), "acyclicer") == "greedy" {
fas = greedyFAS(g, func(e Edge) float64 { return num(asAttrs(g.Edge(e)), "weight") })
} else {
fas = dfsFAS(g)
}
for _, e := range fas {
label := asAttrs(g.Edge(e))
g.RemoveEdge(e)
label["forwardName"] = edgeNameState{name: e.Name, present: e.HasName}
label["reversed"] = true
g.SetEdge(e.W, e.V, label, g.uniqueID("rev"))
}
}

func dfsFAS(g *Graph) []Edge {
var fas []Edge
stack, visited := map[string]bool{}, map[string]bool{}
var dfs func(string)
dfs = func(v string) {
if visited[v] {
return
}
visited[v], stack[v] = true, true
for _, e := range g.OutEdges(v) {
if stack[e.W] {
fas = append(fas, e)
} else {
dfs(e.W)
}
}
delete(stack, v)
}
for _, v := range g.Nodes() {
dfs(v)
}
return fas
}

func undoAcyclic(g *Graph) {
for _, e := range g.Edges() {
label := asAttrs(g.Edge(e))
if !boolValue(label, "reversed") {
continue
}
g.RemoveEdge(e)
state, _ := label["forwardName"].(edgeNameState)
delete(label, "reversed")
delete(label, "forwardName")
if state.present {
g.SetEdge(e.W, e.V, label, state.name)
} else {
g.SetEdge(e.W, e.V, label)
}
}
}
121 changes: 121 additions & 0 deletions acyclic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package dagro

import (
"reflect"
"sort"
"testing"
)

func graphHasDirectedCycle(g *Graph) bool {
state := map[string]uint8{}
var visit func(string) bool
visit = func(v string) bool {
if state[v] == 1 {
return true
}
if state[v] == 2 {
return false
}
state[v] = 1
for _, w := range g.Successors(v) {
if visit(w) {
return true
}
}
state[v] = 2
return false
}
for _, v := range g.Nodes() {
if visit(v) {
return true
}
}
return false
}

func newAcyclicTestGraph(acyclicer string) *Graph {
return NewGraph(GraphOptions{Multigraph: true}).
SetGraph(Attrs{"acyclicer": acyclicer}).
SetDefaultEdgeLabel(func(string, string, *string) any {
return Attrs{"minlen": float64(1), "weight": float64(1)}
})
}

func TestAcyclicRunAndUndo(t *testing.T) {
for _, acyclicer := range []string{"greedy", "dfs", "unknown-should-still-work"} {
t.Run(acyclicer, func(t *testing.T) {
t.Run("acyclic graph unchanged", func(t *testing.T) {
g := newAcyclicTestGraph(acyclicer).
SetPath([]string{"a", "b", "d"}).
SetPath([]string{"a", "c", "d"})
want := append([]Edge(nil), g.Edges()...)
runAcyclic(g)
if graphHasDirectedCycle(g) || !reflect.DeepEqual(g.Edges(), want) {
t.Fatalf("acyclic graph changed: got %#v, want %#v", g.Edges(), want)
}
})

t.Run("breaks cycle and preserves edge count", func(t *testing.T) {
g := newAcyclicTestGraph(acyclicer).SetPath([]string{"a", "b", "c", "d", "a"})
runAcyclic(g)
if graphHasDirectedCycle(g) || g.EdgeCount() != 4 {
t.Fatalf("cycle remains=%v edge count=%d edges=%#v",
graphHasDirectedCycle(g), g.EdgeCount(), g.Edges())
}
})

t.Run("creates reverse multiedge", func(t *testing.T) {
g := newAcyclicTestGraph(acyclicer).SetPath([]string{"a", "b", "a"})
runAcyclic(g)
if graphHasDirectedCycle(g) || g.EdgeCount() != 2 {
t.Fatalf("two-cycle not broken: %#v", g.Edges())
}
if len(g.OutEdges("a", "b")) != 2 && len(g.OutEdges("b", "a")) != 2 {
t.Fatalf("reversal did not create parallel edges: %#v", g.Edges())
}
})

t.Run("undo restores labels names and directions", func(t *testing.T) {
g := newAcyclicTestGraph(acyclicer)
g.SetEdge("a", "b", Attrs{"minlen": float64(2), "weight": float64(3)}, "ab")
g.SetEdge("b", "a", Attrs{"minlen": float64(3), "weight": float64(4)}, "ba")
runAcyclic(g)
undoAcyclic(g)
if g.EdgeCount() != 2 || !g.HasEdge("a", "b", "ab") || !g.HasEdge("b", "a", "ba") {
t.Fatalf("undo identities = %#v", g.Edges())
}
ab := asAttrs(g.EdgeByArgs("a", "b", "ab"))
ba := asAttrs(g.EdgeByArgs("b", "a", "ba"))
if num(ab, "minlen") != 2 || num(ab, "weight") != 3 ||
num(ba, "minlen") != 3 || num(ba, "weight") != 4 {
t.Fatalf("undo labels: ab=%#v ba=%#v", ab, ba)
}
if has(ab, "reversed") || has(ba, "reversed") || has(ab, "forwardName") || has(ba, "forwardName") {
t.Fatalf("undo left internal attrs: ab=%#v ba=%#v", ab, ba)
}
})
})
}
}

func TestGreedyAcyclicPrefersLowWeightEdge(t *testing.T) {
g := newAcyclicTestGraph("greedy").
SetDefaultEdgeLabel(func(string, string, *string) any {
return Attrs{"minlen": float64(1), "weight": float64(2)}
}).
SetPath([]string{"a", "b", "c", "d", "a"})
g.SetEdge("c", "d", Attrs{"minlen": float64(1), "weight": float64(1)})
runAcyclic(g)
if graphHasDirectedCycle(g) || g.HasEdge("c", "d") {
t.Fatalf("greedy did not reverse low-weight edge: %#v", sortedEdgeStrings(g.Edges()))
}
}

func sortedEdgeStrings(edges []Edge) []string {
out := make([]string, len(edges))
for i, e := range edges {
out[i] = e.V + "->" + e.W + ":" + e.Name
}
sort.Strings(out)
return out
}
38 changes: 38 additions & 0 deletions add_border_segments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package dagro

func addBorderSegments(g *Graph) {
var dfs func(string)
dfs = func(v string) {
children := g.Children(v)
node := asAttrs(g.Node(v))
for _, child := range children {
dfs(child)
}
if !has(node, "minRank") {
return
}
node["borderLeft"] = map[int]string{}
node["borderRight"] = map[int]string{}
for rank, max := integer(node, "minRank"), integer(node, "maxRank")+1; rank < max; rank++ {
addSubgraphBorderNode(g, "borderLeft", "_bl", v, node, rank)
addSubgraphBorderNode(g, "borderRight", "_br", v, node, rank)
}
}
for _, v := range g.Children() {
dfs(v)
}
}

func addSubgraphBorderNode(g *Graph, prop, prefix, subgraph string, subgraphNode Attrs, rank int) {
label := Attrs{
"width": float64(0), "height": float64(0), "rank": float64(rank), "borderType": prop,
}
borders := subgraphNode[prop].(map[int]string)
prev := borders[rank-1]
curr := addDummyNode(g, "border", label, prefix)
borders[rank] = curr
_ = g.SetParent(curr, subgraph)
if prev != "" {
g.SetEdge(prev, curr, Attrs{"weight": float64(1)})
}
}
Loading