Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
94a2008
save
Feb 19, 2025
89bd2e8
test
Feb 19, 2025
7bd47a2
d2cycle: trace arcs to shape borders via analytic circle-rect interse…
yasumorishima May 2, 2026
9bf7c38
lib/geo: unit tests for Segment.IntersectCircle
yasumorishima May 2, 2026
1cced2d
d2cycle: trace to true shape border + dedup tangent intersections
yasumorishima May 2, 2026
f9f65e5
d2cycle: clip fallback routes to shape borders too
yasumorishima May 2, 2026
de6ccd6
Merge branch 'master' into feat/cycle-border-tracing
yasumorishima Jul 29, 2026
b45b74f
e2etests: regenerate cycle-diagram goldens after merge
yasumorishima Jul 29, 2026
942d0b8
test(geo): compare IntersectCircle results with a tolerance
yasumorishima Jul 29, 2026
0a6ee9b
Merge branch master of d2lang/d2 into feat/cycle-border-tracing
yasumorishima Aug 7, 2026
fb5cfb7
Merge branch master of d2lang/d2 into feat/cycle-border-tracing
yasumorishima Sep 6, 2026
243ca2b
Render the cycle shape in the scene builder
yasumorishima Sep 6, 2026
56c1823
Place the cycle board at the origin and draw its container
yasumorishima Sep 6, 2026
183f352
Log the cycle layout as cycle, not sequence
yasumorishima Sep 6, 2026
f1828a0
Add the cycle shape to the changelog
yasumorishima Sep 6, 2026
39ba1f5
Merge remote-tracking branch 'upstream/master' into feat/cycle-border…
yasumorishima Sep 8, 2026
800a08b
Add the tala goldens for the cycle diagram
yasumorishima Sep 8, 2026
b8f6ea6
Round the cycle board up to whole pixels
yasumorishima Sep 8, 2026
1936731
Merge remote-tracking branch 'upstream/master' into feat/cycle-border…
yasumorishima Sep 13, 2026
3b89459
Route self loops in cycle diagrams
yasumorishima Sep 14, 2026
0be8017
Keep the cycle radius finite when the ring holds one shape
yasumorishima Sep 14, 2026
021f780
Merge remote-tracking branch 'upstream/master' into feat/cycle-border…
yasumorishima Oct 12, 2026
5d9406c
e2etests: add isometric snapshots for the cycle txtar cases
yasumorishima Oct 12, 2026
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
1 change: 1 addition & 0 deletions ci/release/changelogs/next.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- elk: configure the spacing between edges routed between the same pair of layers with `--elk-edgeEdgeBetweenLayers`. It was previously fixed at 50, which forced large gaps between layers on graphs with many links between adjacent layers.
- renders: add native isometric SVG, PNG, GIF, PDF, and PPTX exports with `--isometric`, source configuration, and Go/JavaScript options
- shape: `cycle` lays a container's children out on a circle and routes their connections as arcs that start and stop on the shape borders

#### Improvements 🧹

Expand Down
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 "github.com/d2lang/d2/d2target"

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

import (
"context"
"math"

"github.com/d2lang/d2/d2graph"
"github.com/d2lang/d2/lib/geo"
"github.com/d2lang/d2/lib/label"
"github.com/d2lang/d2/lib/shape"
"github.com/d2lang/util-go/go2"
)

const (
MIN_RADIUS = 200
PADDING = 20
ARC_STEPS = 30
// SELF_LOOP_MIN_RADIUS keeps the loop of a self edge visible even when the
// shape it hangs off is tiny.
SELF_LOOP_MIN_RADIUS = 20
)

// 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
}

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

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

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

normalizeToOrigin(g)

return nil
}

// normalizeToOrigin shifts the laid out board so that everything it draws --
// the shapes and the arcs between them -- lies at or below the origin corner,
// and records the resulting size on the root. positionObjects centers the ring
// on (0, 0), which leaves the upper left half of the board in negative
// coordinates, but d2layouts fits the container to the nested board and then
// places it at (0, 0), and d2layouts.PositionNested offsets the contents by the
// container position alone -- both assume the nested board already starts at
// the origin. Without this the container is drawn offset from the very children
// it wraps. The arcs are included in the bounds so the container encloses them
// rather than being clipped to the ring of shapes.
func normalizeToOrigin(g *d2graph.Graph) {
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
track := func(x, y float64) {
minX, minY = math.Min(minX, x), math.Min(minY, y)
maxX, maxY = math.Max(maxX, x), math.Max(maxY, y)
}
for _, obj := range g.Objects {
if obj.TopLeft == nil {
continue
}
track(obj.TopLeft.X, obj.TopLeft.Y)
track(obj.TopLeft.X+obj.Width, obj.TopLeft.Y+obj.Height)
}
for _, edge := range g.Edges {
for _, p := range edge.Route {
track(p.X, p.Y)
}
}
if math.IsInf(minX, 1) || math.IsInf(minY, 1) {
return
}

dx, dy := -minX, -minY
if dx != 0 || dy != 0 {
for _, obj := range g.Root.ChildrenArray {
obj.MoveWithDescendants(dx, dy)
}
for _, edge := range g.Edges {
edge.Move(dx, dy)
}
}
// The exporter truncates positions and sizes to whole pixels, and the
// circle leaves shapes on half pixels, so a bare width can round down
// past a child and draw the container border through it. Round up.
g.Root.Width = math.Ceil(maxX - minX)
g.Root.Height = math.Ceil(maxY - minY)
}

func calculateRadius(objects []*d2graph.Object) float64 {
numObjects := float64(len(objects))
maxSize := 0.0
for _, obj := range objects {
size := math.Max(obj.Box.Width, obj.Box.Height)
maxSize = math.Max(maxSize, size)
}
if numObjects < 2 {
// sin(pi/1) is zero to within rounding, so the expression below
// returns about 1e17. Every coordinate on the ring is then spaced
// further apart than the shapes themselves, and the arcs computed
// from them collapse. One shape needs no room for neighbours.
return MIN_RADIUS
}
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))
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,
)
}
}

// 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
}

if edge.Src == edge.Dst {
selfLoopRoute(edge)
return
}

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

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
}

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
}

path := make([]*geo.Point, 0, ARC_STEPS+1)
for i := 0; i <= ARC_STEPS; i++ {
t := float64(i) / float64(ARC_STEPS)
angle := startAngle + t*(endAngle-startAngle)
path = append(path, geo.NewPoint(radius*math.Cos(angle), radius*math.Sin(angle)))
}

// 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
edge.IsCurve = true
}

// selfLoopRoute routes an edge whose source and destination are the same shape
// as a small circle sitting just outside that shape, on the far side from the
// centre of the layout ring. The circular arc code cannot express a self loop:
// the two centres coincide, so the sweep is zero and every clipped endpoint
// collapses onto the same point. That leaves a zero length route, which the
// renderer turns into NaN coordinates and the render target validator rejects.
func selfLoopRoute(edge *d2graph.Edge) {
box := edge.Src.Box
center := edge.Src.Center()

// The ring is centred on the origin, so the shape centre already points
// away from it. A shape sitting exactly on the origin has no outward
// direction of its own; send its loop straight up.
outX, outY := center.X, center.Y
if norm := math.Hypot(outX, outY); norm > 0 {
outX, outY = outX/norm, outY/norm
} else {
outX, outY = 0, -1
}

reach := box.Width + box.Height
far := geo.NewPoint(center.X+outX*reach, center.Y+outY*reach)
anchor := clipToShapeBorder(edge.Src.ToShape(), box, center, far)

loopRadius := math.Max(SELF_LOOP_MIN_RADIUS, math.Min(box.Width, box.Height)/3)
loopCenter := geo.NewPoint(anchor.X+outX*loopRadius, anchor.Y+outY*loopRadius)
// anchor lies on the loop, so start and finish the sweep there.
startAngle := math.Atan2(anchor.Y-loopCenter.Y, anchor.X-loopCenter.X)

path := make([]*geo.Point, 0, ARC_STEPS+1)
for i := 0; i <= ARC_STEPS; i++ {
angle := startAngle + 2*math.Pi*float64(i)/float64(ARC_STEPS)
path = append(path, geo.NewPoint(
loopCenter.X+loopRadius*math.Cos(angle),
loopCenter.Y+loopRadius*math.Sin(angle),
))
}

edge.Route = path
edge.IsCurve = true
}

// 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)

rectBorder := extended
if pts := box.Intersections(geo.Segment{Start: from, End: extended}); len(pts) > 0 {
rectBorder = pts[0]
}
return shape.TraceToShapeBorder(shp, rectBorder, from)
}

// 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

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
}
if !found {
best = a
found = true
continue
}
if forSrc {
if a < best {
best = a
}
} else {
if a > best {
best = a
}
}
}
return best, found
}

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
}

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},
}
}

// positionLabelsIcons applies a sensible default label/icon position when one
// has not been explicitly specified.
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())
}
}
}
}
Loading