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
3 changes: 2 additions & 1 deletion d2ast/keywords.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ var SimpleReservedKeywords = map[string]struct{}{

// ReservedKeywordHolders are reserved keywords that are meaningless on its own and must hold composites
var ReservedKeywordHolders = map[string]struct{}{
"style": {},
"style": {},
"layout": {},
}

// CompositeReservedKeywords are reserved keywords that can hold composites
Expand Down
56 changes: 56 additions & 0 deletions d2compiler/compile.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package d2compiler

import (
"encoding/json"
"encoding/xml"
"fmt"
"html"
Expand Down Expand Up @@ -388,6 +389,13 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
}
c.compileStyle(&obj.Attributes.Style, f.Map())
return
} else if f.Name.ScalarString() == "layout" && f.Name.IsUnquoted() {
if f.Map() == nil || len(f.Map().Fields) == 0 {
c.errorf(f.LastRef().AST(), `"layout" expected to be set to a map of key-values, or contain an additional keyword like "layout.elk.algorithm: stress"`)
return
}
c.compileLayout(&obj.Attributes.Layout, f.Map())
return
}

if obj.Parent != nil {
Expand Down Expand Up @@ -853,6 +861,48 @@ func compileStyleFieldInit(styles *d2graph.Style, f *d2ir.Field) {
}
}

func (c *compiler) compileLayout(layout **json.RawMessage, m *d2ir.Map) {
// Convert the D2 map to a JSON object
layoutMap := make(map[string]interface{})
c.convertMapToJSON(m, layoutMap)

// Marshal to JSON
jsonBytes, err := json.Marshal(layoutMap)
if err != nil {
c.errorf(m.AST(), "failed to compile layout options: %s", err.Error())
return
}

rawMsg := json.RawMessage(jsonBytes)
*layout = &rawMsg
}

func (c *compiler) convertMapToJSON(m *d2ir.Map, result map[string]interface{}) {
for _, f := range m.Fields {
key := f.Name.ScalarString()

if f.Primary() != nil && f.Primary().Value != nil {
// Simple scalar value
val := f.Primary().Value.ScalarString()
// Try to convert to appropriate types
if intVal, err := strconv.ParseInt(val, 10, 64); err == nil {
result[key] = intVal
} else if floatVal, err := strconv.ParseFloat(val, 64); err == nil {
result[key] = floatVal
} else if boolVal, err := strconv.ParseBool(val); err == nil {
result[key] = boolVal
} else {
result[key] = val
}
} else if f.Map() != nil {
// Nested map
nested := make(map[string]interface{})
c.convertMapToJSON(f.Map(), nested)
result[key] = nested
}
}
}

func (c *compiler) compileEdge(obj *d2graph.Object, e *d2ir.Edge) {
edge, err := obj.Connect(e.ID.SrcPath, e.ID.DstPath, e.ID.SrcArrow, e.ID.DstArrow, "")
if err != nil {
Expand Down Expand Up @@ -938,6 +988,12 @@ func (c *compiler) compileEdgeField(edge *d2graph.Edge, f *d2ir.Field) {
}
c.compileStyle(&edge.Attributes.Style, f.Map())
return
} else if f.Name.ScalarString() == "layout" {
if f.Map() == nil {
return
}
c.compileLayout(&edge.Attributes.Layout, f.Map())
return
}

if (f.Name.ScalarString() == "source-arrowhead" || f.Name.ScalarString() == "target-arrowhead") && f.Name.IsUnquoted() {
Expand Down
4 changes: 4 additions & 0 deletions d2graph/d2graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package d2graph
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
Expand Down Expand Up @@ -232,6 +233,9 @@ type Attributes struct {
// These names are attached to the rendered elements in SVG
// so that users can target them however they like outside of D2
Classes []string `json:"classes,omitempty"`

// Layout options passed as JSON to layout engines
Layout *json.RawMessage `json:"layout,omitempty"`
}

// ApplyTextTransform will alter the `Label.Value` of the current object based
Expand Down
172 changes: 141 additions & 31 deletions d2layouts/d2elklayout/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,109 @@ var DefaultOpts = ConfigurableOpts{
var port_spacing = 40.
var edge_node_spacing = 40

// mergeLayoutOptions merges user-defined layout options with base ELK options using deep merge
func mergeLayoutOptions(baseOptions *elkOpts, layoutJSON *json.RawMessage) *elkOpts {
if layoutJSON == nil {
return baseOptions
}

// Debug: print what we received
fmt.Printf("DEBUG ELK: Received layout JSON: %s\n", string(*layoutJSON))

// Parse the JSON layout options
var userOptions map[string]interface{}
if err := json.Unmarshal(*layoutJSON, &userOptions); err != nil {
// Log error but don't fail - use base options
fmt.Printf("DEBUG ELK: Failed to unmarshal layout options: %v\n", err)
return baseOptions
}

// Flatten user options to match base options structure
flatUserOptions := make(map[string]interface{})
flattenForELK(userOptions, "", flatUserOptions)

fmt.Printf("DEBUG ELK: Flattened user options: %+v\n", flatUserOptions)

// Marshal base options to map for merging
baseJSON, err := json.Marshal(baseOptions)
if err != nil {
return baseOptions
}

var baseMap map[string]interface{}
if err := json.Unmarshal(baseJSON, &baseMap); err != nil {
return baseOptions
}

// Merge flattened user options with base options (user options override)
mergedMap := deepMerge(baseMap, flatUserOptions)

// Debug: show what we're about to send to ELK
fmt.Printf("DEBUG ELK: Final merged options: %+v\n", mergedMap)

// Convert back to elkOpts
mergedJSON, err := json.Marshal(mergedMap)
if err != nil {
return baseOptions
}

fmt.Printf("DEBUG ELK: Final JSON sent to ELK: %s\n", string(mergedJSON))

var merged elkOpts
if err := json.Unmarshal(mergedJSON, &merged); err != nil {
fmt.Printf("DEBUG ELK: Failed to unmarshal final options: %v\n", err)
return baseOptions
}

return &merged
}

// deepMerge recursively merges two maps, with values from 'override' taking precedence
func deepMerge(base, override map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})

// Copy base map
for k, v := range base {
result[k] = v
}

// Merge override values
for k, v := range override {
if baseVal, exists := result[k]; exists {
// If both values are maps, merge recursively
if baseMap, baseIsMap := baseVal.(map[string]interface{}); baseIsMap {
if overrideMap, overrideIsMap := v.(map[string]interface{}); overrideIsMap {
result[k] = deepMerge(baseMap, overrideMap)
continue
}
}
}
// Otherwise, override takes precedence
result[k] = v
}

return result
}

// flattenForELK converts nested D2 layout structure to flat ELK key format
// e.g. {"elk": {"algorithm": "stress"}} becomes {"elk.algorithm": "stress"}
func flattenForELK(input map[string]interface{}, prefix string, result map[string]interface{}) {
for key, value := range input {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}

if valueMap, ok := value.(map[string]interface{}); ok {
// Recursively flatten nested maps
flattenForELK(valueMap, fullKey, result)
} else {
// Store the flattened key-value pair
result[fullKey] = value
}
}
}

type elkOpts struct {
EdgeNode int `json:"elk.spacing.edgeNode,omitempty"`
FixedAlignment string `json:"elk.layered.nodePlacement.bk.fixedAlignment,omitempty"`
Expand Down Expand Up @@ -174,41 +277,46 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
}

elkGraph := &ELKGraph{
ID: "",
LayoutOptions: &elkOpts{
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
EdgeNode: edge_node_spacing,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
Algorithm: opts.Algorithm,
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
},
baseRootOpts := &elkOpts{
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
EdgeNode: edge_node_spacing,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
Algorithm: opts.Algorithm,
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
},
}

elkGraph := &ELKGraph{
ID: "",
LayoutOptions: mergeLayoutOptions(baseRootOpts, g.Root.Attributes.Layout),
}
if elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
// +5 for a tiny bit of padding
elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(g.Root, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch g.Root.Direction.Value {
case "down":
elkGraph.LayoutOptions.Direction = Down
case "up":
elkGraph.LayoutOptions.Direction = Up
case "right":
elkGraph.LayoutOptions.Direction = Right
case "left":
elkGraph.LayoutOptions.Direction = Left
default:
elkGraph.LayoutOptions.Direction = Down
// Only set direction if not already set by layout options
if elkGraph.LayoutOptions.Direction == "" {
switch g.Root.Direction.Value {
case "down":
elkGraph.LayoutOptions.Direction = Down
case "up":
elkGraph.LayoutOptions.Direction = Up
case "right":
elkGraph.LayoutOptions.Direction = Right
case "left":
elkGraph.LayoutOptions.Direction = Left
default:
elkGraph.LayoutOptions.Direction = Down
}
}

// set label and icon positions for ELK
Expand Down Expand Up @@ -272,7 +380,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}

if len(obj.ChildrenArray) > 0 {
n.LayoutOptions = &elkOpts{
baseOpts := &elkOpts{
ForceNodeModelOrder: true,
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
Expand All @@ -290,6 +398,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
Padding: opts.Padding,
},
}
n.LayoutOptions = mergeLayoutOptions(baseOpts, obj.Attributes.Layout)
if n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(obj, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
Expand All @@ -301,9 +410,10 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(width)), int(math.Ceil(height)))
}
} else {
n.LayoutOptions = &elkOpts{
baseOpts := &elkOpts{
SelfLoopDistribution: "EQUALLY",
}
n.LayoutOptions = mergeLayoutOptions(baseOpts, obj.Attributes.Layout)
}

if obj.IsContainer() {
Expand Down
9 changes: 5 additions & 4 deletions e2etests/stable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1872,18 +1872,19 @@ c: "just an actor"
shape: sequence_diagram

CLI; d2ast; d2compiler; d2layout; d2exporter; d2themes; d2renderer; d2sequencelayout; d2dagrelayout
d2layout.layout_: layout

CLI -> d2ast: "'How this is rendered: {...}'"
d2ast -> CLI: tokenized AST
CLI -> d2compiler: compile AST
d2compiler."measurements also take place"
d2compiler -> CLI: objects and edges
CLI -> d2layout.layout: run layout engines
d2layout.layout -> d2sequencelayout: run engine on shape: sequence_diagram, temporarily remove
CLI -> d2layout.layout_: run layout engines
d2layout.layout_ -> d2sequencelayout: run engine on shape: sequence_diagram, temporarily remove
only if root is not sequence: {
d2layout.layout -> d2dagrelayout: run core engine on rest
d2layout.layout_ -> d2dagrelayout: run core engine on rest
}
d2layout.layout <- d2sequencelayout: add back in sequence diagrams
d2layout.layout_ <- d2sequencelayout: add back in sequence diagrams
d2layout -> CLI: diagram with correct positions and dimensions
CLI -> d2exporter: export diagram with chosen theme and renderer
d2exporter.export -> d2themes: get theme styles
Expand Down
7 changes: 4 additions & 3 deletions e2etests/testdata/files/nesting_power.d2
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
l: Left Constant Near {
layout_: layout
direction: right
near: center-left

default -> layout -> here
default -> layout_ -> here
default: {
direction: right
dagre -- elk -- tala: or
}
default.* -> layout: runs this
default.* -> layout_: runs this

here: {
grid-columns: 3
Expand Down Expand Up @@ -54,7 +55,7 @@ l: Left Constant Near {
here.this.row 1 <- default.tala: straight edge across nested types {class: green}
}

center -> directions: default layout
center -> directions: default layout_

center: {
rectangle: {shape: "rectangle"}
Expand Down
Loading