Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cycle diagram #1 #2369

Closed
wants to merge 73 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
8893981
initial push
Mayank77maruti Feb 21, 2025
527edda
iteration 1
Mayank77maruti Feb 21, 2025
05a8e93
iteration 2
Mayank77maruti Feb 21, 2025
495bac6
iteration 3
Mayank77maruti Feb 21, 2025
8084866
iteration 4
Mayank77maruti Feb 21, 2025
509a344
iteration 5
Mayank77maruti Feb 21, 2025
0482545
iteration 6
Mayank77maruti Feb 21, 2025
9daef7f
iteration 6
Mayank77maruti Feb 21, 2025
77f88df
iteration 7
Mayank77maruti Feb 21, 2025
42c2a1f
iteration 7
Mayank77maruti Feb 21, 2025
6722e3f
test 1
Mayank77maruti Feb 21, 2025
79d013d
test 2
Mayank77maruti Feb 21, 2025
7bc2b2a
test 2
Mayank77maruti Feb 21, 2025
f6c3bdc
test 2
Mayank77maruti Feb 21, 2025
dd28b2c
test 2
Mayank77maruti Feb 21, 2025
b2a6395
try
Mayank77maruti Feb 22, 2025
39d2022
try
Mayank77maruti Feb 22, 2025
3c1be1e
try
Mayank77maruti Feb 22, 2025
d00529c
try
Mayank77maruti Feb 22, 2025
bef6b8b
try
Mayank77maruti Feb 22, 2025
65d558e
try
Mayank77maruti Feb 22, 2025
b568305
try
Mayank77maruti Feb 22, 2025
be5ba61
try
Mayank77maruti Feb 22, 2025
85c1ea1
try
Mayank77maruti Feb 22, 2025
6736a70
try
Mayank77maruti Feb 22, 2025
b99d79c
try
Mayank77maruti Feb 22, 2025
703ab33
try
Mayank77maruti Feb 22, 2025
9eaa03c
try
Mayank77maruti Feb 22, 2025
36ccdb6
try
Mayank77maruti Feb 22, 2025
a852643
try
Mayank77maruti Feb 22, 2025
17cca5e
try
Mayank77maruti Feb 22, 2025
578d5bc
try
Mayank77maruti Feb 22, 2025
4877190
try
Mayank77maruti Feb 22, 2025
da8f3bf
try
Mayank77maruti Feb 22, 2025
6ec83ff
try
Mayank77maruti Feb 22, 2025
a9a2782
try
Mayank77maruti Feb 22, 2025
8135688
try
Mayank77maruti Feb 22, 2025
a5ed6aa
try
Mayank77maruti Feb 22, 2025
738af0e
try
Mayank77maruti Feb 22, 2025
59f4a1b
try
Mayank77maruti Feb 22, 2025
53423db
try
Mayank77maruti Feb 22, 2025
b31f872
try
Mayank77maruti Feb 22, 2025
04eb007
try
Mayank77maruti Feb 22, 2025
9731ae6
try
Mayank77maruti Feb 22, 2025
3a0bab3
try
Mayank77maruti Feb 23, 2025
d4dac38
try
Mayank77maruti Feb 23, 2025
1d8426e
try
Mayank77maruti Feb 23, 2025
a0ed536
try
Mayank77maruti Feb 23, 2025
bcf1a00
try
Mayank77maruti Feb 23, 2025
9e47cf0
try
Mayank77maruti Feb 23, 2025
4ee9617
try
Mayank77maruti Feb 23, 2025
460a118
try
Mayank77maruti Feb 23, 2025
ba50bfd
try
Mayank77maruti Feb 23, 2025
314844c
try
Mayank77maruti Feb 23, 2025
357befa
try
Mayank77maruti Feb 23, 2025
ee52378
try
Mayank77maruti Feb 23, 2025
e541734
try
Mayank77maruti Feb 23, 2025
2fc8183
try
Mayank77maruti Feb 23, 2025
8580d62
try
Mayank77maruti Feb 23, 2025
d3be0f5
try
Mayank77maruti Feb 23, 2025
1ebb2db
try
Mayank77maruti Feb 23, 2025
7cd533d
try
Mayank77maruti Feb 23, 2025
5b28501
lint try
Mayank77maruti Feb 23, 2025
090a225
lint try
Mayank77maruti Feb 23, 2025
564c1f8
try
Mayank77maruti Feb 26, 2025
d49b463
try
Mayank77maruti Feb 26, 2025
adf3c65
try
Mayank77maruti Mar 8, 2025
d594af3
try
Mayank77maruti Mar 8, 2025
3e697b5
try
Mayank77maruti Mar 8, 2025
95e0aae
try
Mayank77maruti Mar 8, 2025
dfc4e4b
try
Mayank77maruti Mar 8, 2025
fd8d01d
try
Mayank77maruti Mar 8, 2025
f37193a
try
Mayank77maruti Mar 8, 2025
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
Empty file added .emptyAllowedSigners
Empty file.
7 changes: 7 additions & 0 deletions d2graph/cyclediagram.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package d2graph

import "oss.terrastruct.com/d2/d2target"

func (obj *Object) IsCycleDiagram() bool {
return obj != nil && obj.Shape.Value == d2target.ShapeCycleDiagram
}
337 changes: 337 additions & 0 deletions d2layouts/d2cycle/layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
package d2cycle

import (
"context"
"math"
"sort"

"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/util-go/go2"
)

const (
MIN_RADIUS = 200
PADDING = 20
MIN_SEGMENT_LEN = 10
ARC_STEPS = 100
)

// Layout lays out the graph and computes curved edge routes.
func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) error {
objects := g.Root.ChildrenArray
if len(objects) == 0 {
return nil
}

for _, obj := range g.Objects {
positionLabelsIcons(obj)
}

radius := calculateRadius(objects)
positionObjects(objects, radius)

for _, edge := range g.Edges {
createCircularArc(edge)
}

return nil
}

// calculateRadius computes a radius ensuring that the circular layout does not overlap.
// For each object we compute the half-diagonal (i.e. the radius of the minimal enclosing circle),
// then ensure the chord between two adjacent centers (2*radius*sin(π/n)) is at least
// 2*(maxHalfDiag + PADDING). We also add a safety factor (1.2) to avoid floating-point issues.
func calculateRadius(objects []*d2graph.Object) float64 {
if len(objects) < 2 {
return MIN_RADIUS
}
numObjects := float64(len(objects))
maxHalfDiag := 0.0
for _, obj := range objects {
halfDiag := math.Hypot(obj.Box.Width/2, obj.Box.Height/2)
if halfDiag > maxHalfDiag {
maxHalfDiag = halfDiag
}
}
// We need the chord (distance between adjacent centers) to be at least:
// 2*(maxHalfDiag + PADDING)
// and since chord = 2*radius*sin(π/n), we require:
// radius >= (maxHalfDiag + PADDING) / sin(π/n)
minRadius := (maxHalfDiag + PADDING) / math.Sin(math.Pi/numObjects)
// Apply a safety factor of 1.2 and ensure it doesn't fall below MIN_RADIUS.
return math.Max(minRadius*1.2, MIN_RADIUS)
}

func positionObjects(objects []*d2graph.Object, radius float64) {
numObjects := float64(len(objects))
angleOffset := -math.Pi / 2

for i, obj := range objects {
angle := angleOffset + (2*math.Pi*float64(i)/numObjects)
x := radius * math.Cos(angle)
y := radius * math.Sin(angle)
obj.TopLeft = geo.NewPoint(
x-obj.Box.Width/2,
y-obj.Box.Height/2,
)
}
}

func createCircularArc(edge *d2graph.Edge) {
if edge.Src == nil || edge.Dst == nil {
return
}

srcCenter := edge.Src.Center()
dstCenter := edge.Dst.Center()

srcAngle := math.Atan2(srcCenter.Y, srcCenter.X)
dstAngle := math.Atan2(dstCenter.Y, dstCenter.X)
if dstAngle < srcAngle {
dstAngle += 2 * math.Pi
}

arcRadius := math.Hypot(srcCenter.X, srcCenter.Y)

path := make([]*geo.Point, 0, ARC_STEPS+1)
for i := 0; i <= ARC_STEPS; i++ {
t := float64(i) / float64(ARC_STEPS)
angle := srcAngle + t*(dstAngle-srcAngle)
x := arcRadius * math.Cos(angle)
y := arcRadius * math.Sin(angle)
path = append(path, geo.NewPoint(x, y))
}
path[0] = srcCenter
path[len(path)-1] = dstCenter

// Clamp endpoints to the boundaries of the source and destination boxes.
_, newSrc := clampPointOutsideBox(edge.Src.Box, path, 0)
_, newDst := clampPointOutsideBoxReverse(edge.Dst.Box, path, len(path)-1)
path[0] = newSrc
path[len(path)-1] = newDst

// Trim redundant path points that fall inside node boundaries.
path = trimPathPoints(path, edge.Src.Box)
path = trimPathPoints(path, edge.Dst.Box)

edge.Route = path
edge.IsCurve = true

if len(edge.Route) >= 2 {
lastIndex := len(edge.Route) - 1
lastPoint := edge.Route[lastIndex]
secondLastPoint := edge.Route[lastIndex-1]

tangentX := -lastPoint.Y
tangentY := lastPoint.X
mag := math.Hypot(tangentX, tangentY)
if mag > 0 {
tangentX /= mag
tangentY /= mag
}
const MIN_SEGMENT_LEN = 4.159

dx := lastPoint.X - secondLastPoint.X
dy := lastPoint.Y - secondLastPoint.Y
segLength := math.Hypot(dx, dy)
if segLength > 0 {
currentDirX := dx / segLength
currentDirY := dy / segLength

// Check if we need to adjust the direction
if segLength < MIN_SEGMENT_LEN || (currentDirX*tangentX+currentDirY*tangentY) < 0.999 {
adjustLength := MIN_SEGMENT_LEN
if segLength >= MIN_SEGMENT_LEN {
adjustLength = segLength
}
newSecondLastX := lastPoint.X - tangentX*adjustLength
newSecondLastY := lastPoint.Y - tangentY*adjustLength
edge.Route[lastIndex-1] = geo.NewPoint(newSecondLastX, newSecondLastY)
}
}
}
}

// clampPointOutsideBox walks forward along the path until it finds a point outside the box,
// then replaces the point with a precise intersection.
func clampPointOutsideBox(box *geo.Box, path []*geo.Point, startIdx int) (int, *geo.Point) {
if startIdx >= len(path)-1 {
return startIdx, path[startIdx]
}
if !boxContains(box, path[startIdx]) {
return startIdx, path[startIdx]
}

for i := startIdx + 1; i < len(path); i++ {
if boxContains(box, path[i]) {
continue
}
seg := geo.NewSegment(path[i-1], path[i])
inter := findPreciseIntersection(box, *seg)
if inter != nil {
return i, inter
}
return i, path[i]
}
return len(path)-1, path[len(path)-1]
}

// clampPointOutsideBoxReverse works similarly but in reverse order.
func clampPointOutsideBoxReverse(box *geo.Box, path []*geo.Point, endIdx int) (int, *geo.Point) {
if endIdx <= 0 {
return endIdx, path[endIdx]
}
if !boxContains(box, path[endIdx]) {
return endIdx, path[endIdx]
}

for j := endIdx - 1; j >= 0; j-- {
if boxContains(box, path[j]) {
continue
}
seg := geo.NewSegment(path[j], path[j+1])
inter := findPreciseIntersection(box, *seg)
if inter != nil {
return j, inter
}
return j, path[j]
}
return 0, path[0]
}

// findPreciseIntersection calculates intersection points between seg and all four sides of the box,
// then returns the intersection closest to seg.Start.
func findPreciseIntersection(box *geo.Box, seg geo.Segment) *geo.Point {
intersections := []struct {
point *geo.Point
t float64
}{}

left := box.TopLeft.X
right := box.TopLeft.X + box.Width
top := box.TopLeft.Y
bottom := box.TopLeft.Y + box.Height

dx := seg.End.X - seg.Start.X
dy := seg.End.Y - seg.Start.Y

// Check vertical boundaries.
if dx != 0 {
t := (left - seg.Start.X) / dx
if t >= 0 && t <= 1 {
y := seg.Start.Y + t*dy
if y >= top && y <= bottom {
intersections = append(intersections, struct {
point *geo.Point
t float64
}{geo.NewPoint(left, y), t})
}
}
t = (right - seg.Start.X) / dx
if t >= 0 && t <= 1 {
y := seg.Start.Y + t*dy
if y >= top && y <= bottom {
intersections = append(intersections, struct {
point *geo.Point
t float64
}{geo.NewPoint(right, y), t})
}
}
}

// Check horizontal boundaries.
if dy != 0 {
t := (top - seg.Start.Y) / dy
if t >= 0 && t <= 1 {
x := seg.Start.X + t*dx
if x >= left && x <= right {
intersections = append(intersections, struct {
point *geo.Point
t float64
}{geo.NewPoint(x, top), t})
}
}
t = (bottom - seg.Start.Y) / dy
if t >= 0 && t <= 1 {
x := seg.Start.X + t*dx
if x >= left && x <= right {
intersections = append(intersections, struct {
point *geo.Point
t float64
}{geo.NewPoint(x, bottom), t})
}
}
}

if len(intersections) == 0 {
return nil
}

// Sort intersections by t (distance from seg.Start) and return the closest.
sort.Slice(intersections, func(i, j int) bool {
return intersections[i].t < intersections[j].t
})
return intersections[0].point
}

// trimPathPoints removes intermediate points that fall inside the given box while preserving endpoints.
func trimPathPoints(path []*geo.Point, box *geo.Box) []*geo.Point {
if len(path) <= 2 {
return path
}
trimmed := []*geo.Point{path[0]}
for i := 1; i < len(path)-1; i++ {
if !boxContains(box, path[i]) {
trimmed = append(trimmed, path[i])
}
}
trimmed = append(trimmed, path[len(path)-1])
return trimmed
}

// boxContains uses strict inequalities so that points exactly on the boundary are considered outside.
func boxContains(b *geo.Box, p *geo.Point) bool {
return p.X > b.TopLeft.X &&
p.X < b.TopLeft.X+b.Width &&
p.Y > b.TopLeft.Y &&
p.Y < b.TopLeft.Y+b.Height
}

func positionLabelsIcons(obj *d2graph.Object) {
if obj.Icon != nil && obj.IconPosition == nil {
if len(obj.ChildrenArray) > 0 {
obj.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
if obj.LabelPosition == nil {
obj.LabelPosition = go2.Pointer(label.OutsideTopRight.String())
return
}
} else if obj.SQLTable != nil || obj.Class != nil || obj.Language != "" {
obj.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
} else {
obj.IconPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
}

if obj.HasLabel() && obj.LabelPosition == nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else if obj.HasOutsideBottomLabel() {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
} else if obj.Icon != nil {
obj.LabelPosition = go2.Pointer(label.InsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}

if float64(obj.LabelDimensions.Width) > obj.Width ||
float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
}
}
}
}
10 changes: 10 additions & 0 deletions d2layouts/d2layouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2cycle"
"oss.terrastruct.com/d2/d2layouts/d2grid"
"oss.terrastruct.com/d2/d2layouts/d2near"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
Expand All @@ -26,6 +27,7 @@ const (
ConstantNearGraph DiagramType = "constant-near"
GridDiagram DiagramType = "grid-diagram"
SequenceDiagram DiagramType = "sequence-diagram"
CycleDiagram DiagramType = "cycle-diagram"
)

type GraphInfo struct {
Expand Down Expand Up @@ -260,6 +262,12 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co
if err != nil {
return err
}
case CycleDiagram:
log.Debug(ctx, "layout sequence", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
err = d2cycle.Layout(ctx, g, coreLayout)
if err != nil {
return err
}
default:
log.Debug(ctx, "default layout", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
err := coreLayout(ctx, g)
Expand Down Expand Up @@ -360,6 +368,8 @@ func NestedGraphInfo(obj *d2graph.Object) (gi GraphInfo) {
gi.DiagramType = SequenceDiagram
} else if obj.IsGridDiagram() {
gi.DiagramType = GridDiagram
} else if obj.IsCycleDiagram() {
gi.DiagramType = CycleDiagram
}
return gi
}
Expand Down
Loading