Skip to content
Open
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
228 changes: 123 additions & 105 deletions d2layouts/d2cycle/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,33 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
"oss.terrastruct.com/util-go/go2"
)

const (
MIN_RADIUS = 200
PADDING = 20
MIN_SEGMENT_LEN = 10
ARC_STEPS = 30 // high resolution for smooth arcs

MIN_RADIUS = 200
PADDING = 20
ARC_STEPS = 30
)

// Layout arranges nodes in a circle, ensures label/icon positions are set,
// then routes edges with arcs that get clipped at node borders.
// Layout arranges nodes in a circle and routes each edge as a circular arc
// that starts and ends on the borders of its source and destination shapes.
func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) error {
objects := g.Root.ChildrenArray
if len(objects) == 0 {
return nil
}

// Make sure every object that has label/icon also has a default position
for _, obj := range g.Objects {
positionLabelsIcons(obj)
}

// Arrange objects in a circle
radius := calculateRadius(objects)
positionObjects(objects, radius)

// Create arcs
for _, edge := range g.Edges {
createCircularArc(edge)
createCircularArc(edge, radius)
}

return nil
Expand All @@ -50,158 +46,182 @@ func calculateRadius(objects []*d2graph.Object) float64 {
size := math.Max(obj.Box.Width, obj.Box.Height)
maxSize = math.Max(maxSize, size)
}
// ensure enough radius to fit all objects
minRadius := (maxSize/2.0 + PADDING) / math.Sin(math.Pi/numObjects)
return math.Max(minRadius, MIN_RADIUS)
}

func positionObjects(objects []*d2graph.Object, radius float64) {
numObjects := float64(len(objects))
// Offset so i=0 is top-center
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)

// center the box at (x, y)
obj.TopLeft = geo.NewPoint(
x-obj.Box.Width/2,
y-obj.Box.Height/2,
)
}
}

// createCircularArc samples a smooth arc from center to center, then
// forces the endpoints onto each shape's border, and finally calls
// TraceToShape to clip any additional overrun.
func createCircularArc(edge *d2graph.Edge) {
// createCircularArc routes a single edge as a circular arc whose endpoints
// lie exactly on the borders of the source and destination shapes. The arc
// belongs to the layout circle, centered at the origin with the given radius;
// the source and destination shape centers both lie on that circle.
func createCircularArc(edge *d2graph.Edge, radius float64) {
if edge.Src == nil || edge.Dst == nil {
return
}

srcCenter := edge.Src.Center()
dstCenter := edge.Dst.Center()
origin := geo.NewPoint(0, 0)

// angles from origin
srcAngle := math.Atan2(srcCenter.Y, srcCenter.X)
dstAngle := math.Atan2(dstCenter.Y, dstCenter.X)
if dstAngle < srcAngle {
dstAngle += 2 * math.Pi
}
sweep := dstAngle - srcAngle
srcShape := edge.Src.ToShape()
dstShape := edge.Dst.ToShape()
if sweep <= 0 {
fallbackStraightRoute(edge, srcShape, dstShape, srcCenter, dstCenter)
return
}

arcRadius := math.Hypot(srcCenter.X, srcCenter.Y)
startAngle, hasStart := nextBoundaryAngle(edge.Src.Box, origin, radius, srcAngle, sweep, true)
endAngle, hasEnd := nextBoundaryAngle(edge.Dst.Box, origin, radius, srcAngle, sweep, false)
if !hasStart || !hasEnd || endAngle <= startAngle {
fallbackStraightRoute(edge, srcShape, dstShape, srcCenter, dstCenter)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Sample points along the arc
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))
angle := startAngle + t*(endAngle-startAngle)
path = append(path, geo.NewPoint(radius*math.Cos(angle), radius*math.Sin(angle)))
}
// Set start/end to exact centers
path[0] = srcCenter
path[len(path)-1] = dstCenter

// Use TraceToShape to clip route to node borders
// path[0] / path[len-1] sit on the bounding-box border of the source /
// destination shape. For non-rectangular shapes (circle, oval, hexagon,
// cloud, ...) the bounding box border is not the shape border, so trace
// each endpoint inward from the shape center to the actual shape outline.
// TraceToShapeBorder is a no-op for rectangular shapes.
path[0] = shape.TraceToShapeBorder(srcShape, path[0], srcCenter)
path[len(path)-1] = shape.TraceToShapeBorder(dstShape, path[len(path)-1], dstCenter)

edge.Route = path
startIndex, endIndex := edge.TraceToShape(edge.Route, 0, len(edge.Route)-1)
if startIndex < endIndex {
edge.Route = edge.Route[startIndex : endIndex+1]
}
edge.IsCurve = true
}

// clampPointOutsideBox walks forward from 'startIdx' until the path segment
// leaves the bounding box. Then it sets path[startIdx] to the intersection.
// If we never find it, we return (startIdx, path[startIdx]) meaning we can't clamp.
func clampPointOutsideBox(box *geo.Box, path []*geo.Point, startIdx int) (int, *geo.Point) {
if startIdx >= len(path)-1 {
return startIdx, path[startIdx]
}
// If path[startIdx] is outside, no clamp needed
if !boxContains(box, path[startIdx]) {
return startIdx, path[startIdx]
// fallbackStraightRoute renders a straight connection whose endpoints are
// clipped to the source and destination shape borders along the line between
// the two centers, used when the analytic arc geometry degenerates (zero
// sweep, no boundary crossing in the arc range, etc.).
func fallbackStraightRoute(edge *d2graph.Edge, srcShape, dstShape shape.Shape, srcCenter, dstCenter *geo.Point) {
srcBorder := clipToShapeBorder(srcShape, edge.Src.Box, srcCenter, dstCenter)
dstBorder := clipToShapeBorder(dstShape, edge.Dst.Box, dstCenter, srcCenter)
edge.Route = []*geo.Point{srcBorder, dstBorder}
edge.IsCurve = false
}

// clipToShapeBorder returns the point where a ray from `from` (assumed inside
// `box`) toward `toward` first exits the actual shape outline. The bounding
// box is consulted first to obtain a rectangular border point, then the shape
// helper refines it for non-rectangular shapes.
func clipToShapeBorder(shp shape.Shape, box *geo.Box, from, toward *geo.Point) *geo.Point {
dx := toward.X - from.X
dy := toward.Y - from.Y
dist := math.Hypot(dx, dy)
if dist == 0 {
return from
}
// Extend the ray well past `toward` so the segment definitely exits the
// box even when `toward` itself sits inside the box.
diag := math.Hypot(box.Width, box.Height)
scale := (dist + 2*diag) / dist
extended := geo.NewPoint(from.X+dx*scale, from.Y+dy*scale)

// Walk forward looking for outside
for i := startIdx + 1; i < len(path); i++ {
insideNext := boxContains(box, path[i])
if insideNext {
// still inside -> keep going
continue
}
// crossing from inside to outside between path[i-1], path[i]
seg := geo.NewSegment(path[i-1], path[i])
inters := boxIntersections(box, *seg)
if len(inters) > 0 {
// use first intersection
return i, inters[0]
}
// fallback => no intersection found
return i, path[i]
rectBorder := extended
if pts := box.Intersections(geo.Segment{Start: from, End: extended}); len(pts) > 0 {
rectBorder = pts[0]
}
// entire remainder is inside, so we can't clamp
// Just return the end
last := len(path) - 1
return last, path[last]
return shape.TraceToShapeBorder(shp, rectBorder, from)
}

// clampPointOutsideBoxReverse scans backward from endIdx while path[j] is in the box.
// Once we find crossing (outside→inside), we return (j, intersection).
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]) {
// already outside
return endIdx, path[endIdx]
}
// nextBoundaryAngle scans the angles where the layout circle crosses an edge
// of the box. When forSrc is true it returns the smallest such angle strictly
// greater than srcAngle (the point where the arc exits the source box). When
// forSrc is false it returns the largest such angle strictly less than
// srcAngle+sweep (the point where the arc enters the destination box). The
// boolean is false when no crossing exists in the (srcAngle, srcAngle+sweep)
// range, in which case the caller falls back to the shape center.
func nextBoundaryAngle(box *geo.Box, origin *geo.Point, radius, srcAngle, sweep float64, forSrc bool) (float64, bool) {
candidates := boxCircleIntersectionAngles(box, origin, radius)
endAngle := srcAngle + sweep

for j := endIdx - 1; j >= 0; j-- {
if boxContains(box, path[j]) {
var best float64
found := false
for _, raw := range candidates {
a := raw
for a <= srcAngle {
a += 2 * math.Pi
}
for a > srcAngle+2*math.Pi {
a -= 2 * math.Pi
}
if a >= endAngle {
continue
}
// crossing from outside -> inside between path[j], path[j+1]
seg := geo.NewSegment(path[j], path[j+1])
inters := boxIntersections(box, *seg)
if len(inters) > 0 {
return j, inters[0]
if !found {
best = a
found = true
continue
}
if forSrc {
if a < best {
best = a
}
} else {
if a > best {
best = a
}
}
return j, path[j]
}

// entire path inside
return 0, path[0]
return best, found
}

// Helper if your geo.Box doesn’t implement Contains()
func boxContains(b *geo.Box, p *geo.Point) bool {
// typical bounding-box check
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 boxCircleIntersectionAngles(box *geo.Box, origin *geo.Point, radius float64) []float64 {
edges := boxEdges(box)
var angles []float64
for _, e := range edges {
for _, p := range e.IntersectCircle(origin, radius) {
angles = append(angles, math.Atan2(p.Y-origin.Y, p.X-origin.X))
}
}
return angles
}

// Helper if your geo.Box doesn’t implement Intersections(geo.Segment) yet
func boxIntersections(b *geo.Box, seg geo.Segment) []*geo.Point {
// We'll assume d2's standard geo.Box has a built-in Intersections(*Segment) method.
// If not, implement manually. For example, checking each of the 4 edges:
// left, right, top, bottom
// For simplicity, if you do have b.Intersections(...) you can just do:
// return b.Intersections(seg)
return b.Intersections(seg)
// If you don't have that, you'd code the line-rect intersection yourself.
func boxEdges(box *geo.Box) []geo.Segment {
tl := box.TopLeft
tr := geo.NewPoint(tl.X+box.Width, tl.Y)
bl := geo.NewPoint(tl.X, tl.Y+box.Height)
br := geo.NewPoint(tl.X+box.Width, tl.Y+box.Height)
return []geo.Segment{
{Start: tl, End: tr},
{Start: tr, End: br},
{Start: br, End: bl},
{Start: bl, End: tl},
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// positionLabelsIcons is basically your logic that sets default label/icon positions if needed
// positionLabelsIcons applies a sensible default label/icon position when one
// has not been explicitly specified.
func positionLabelsIcons(obj *d2graph.Object) {
// If there's an icon but no icon position, give it a default
if obj.Icon != nil && obj.IconPosition == nil {
if len(obj.ChildrenArray) > 0 {
obj.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
Expand All @@ -216,7 +236,6 @@ func positionLabelsIcons(obj *d2graph.Object) {
}
}

// If there's a label but no label position, give it a default
if obj.HasLabel() && obj.LabelPosition == nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
Expand All @@ -228,7 +247,6 @@ func positionLabelsIcons(obj *d2graph.Object) {
obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}

// If the label is bigger than the shape, fallback to outside positions
if float64(obj.LabelDimensions.Width) > obj.Width ||
float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
Expand Down
Loading